feat(M02-C): 建立RBAC与门店数据范围
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs"
|
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -119,6 +119,29 @@ export class AuthRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!user || user.status !== 'ACTIVE') throw new Error('USER_DISABLED');
|
if (!user || user.status !== 'ACTIVE') throw new Error('USER_DISABLED');
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT IGNORE INTO qipai_roles (tenant_id, code, name) VALUES
|
||||||
|
(?, 'CUSTOMER', '顾客'),
|
||||||
|
(?, 'CLEANER', '保洁员'),
|
||||||
|
(?, 'STAFF', '门店员工'),
|
||||||
|
(?, 'STORE_ADMIN', '门店管理员'),
|
||||||
|
(?, 'TENANT_ADMIN', '租户管理员'),
|
||||||
|
(?, 'PLATFORM_ADMIN', '平台管理员')`,
|
||||||
|
Array(6).fill(input.context.tenantId)
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT IGNORE INTO qipai_user_roles (tenant_id, user_id, role_id)
|
||||||
|
SELECT ?, ?, id FROM qipai_roles
|
||||||
|
WHERE tenant_id = ? AND code = 'CUSTOMER' AND status = 'ACTIVE'`,
|
||||||
|
[input.context.tenantId, user.id, input.context.tenantId]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
|
||||||
|
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||||
|
INNER JOIN qipai_permissions p ON p.code IN ('profile.read', 'order.self.read')
|
||||||
|
WHERE r.tenant_id = ? AND r.code = 'CUSTOMER'`,
|
||||||
|
[input.context.tenantId, input.context.tenantId]
|
||||||
|
);
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
`INSERT INTO qipai_auth_sessions
|
`INSERT INTO qipai_auth_sessions
|
||||||
(id, tenant_id, platform_app_id, user_id, role_version, expires_at, ip, user_agent)
|
(id, tenant_id, platform_app_id, user_id, role_version, expires_at, ip, user_agent)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
|
||||||
|
export const roleCodes = [
|
||||||
|
'CUSTOMER', 'CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface AccessProfile {
|
||||||
|
roles: string[];
|
||||||
|
capabilities: string[];
|
||||||
|
storeIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeRow extends RowDataPacket { code: string }
|
||||||
|
interface StoreRow extends RowDataPacket { storeId: string }
|
||||||
|
|
||||||
|
export class RbacRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async ensureCustomerRole(tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.pool.execute(
|
||||||
|
`INSERT IGNORE INTO qipai_roles (tenant_id, code, name) VALUES
|
||||||
|
(?, 'CUSTOMER', '顾客'), (?, 'CLEANER', '保洁员'), (?, 'STAFF', '门店员工'),
|
||||||
|
(?, 'STORE_ADMIN', '门店管理员'), (?, 'TENANT_ADMIN', '租户管理员'),
|
||||||
|
(?, 'PLATFORM_ADMIN', '平台管理员')`,
|
||||||
|
Array(6).fill(tenantId)
|
||||||
|
);
|
||||||
|
await this.pool.execute(
|
||||||
|
`INSERT IGNORE INTO qipai_user_roles (tenant_id, user_id, role_id)
|
||||||
|
SELECT ?, ?, id FROM qipai_roles
|
||||||
|
WHERE tenant_id = ? AND code = 'CUSTOMER' AND status = 'ACTIVE'`,
|
||||||
|
[tenantId, userId, tenantId]
|
||||||
|
);
|
||||||
|
await this.pool.execute(
|
||||||
|
`INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
|
||||||
|
SELECT ?, r.id, p.id
|
||||||
|
FROM qipai_roles r
|
||||||
|
INNER JOIN qipai_permissions p ON p.code IN ('profile.read', 'order.self.read')
|
||||||
|
WHERE r.tenant_id = ? AND r.code = 'CUSTOMER'`,
|
||||||
|
[tenantId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> {
|
||||||
|
const [roles] = await this.pool.execute<CodeRow[]>(
|
||||||
|
`SELECT DISTINCT r.code FROM qipai_user_roles ur
|
||||||
|
INNER JOIN qipai_roles r
|
||||||
|
ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||||
|
AND r.status = 'ACTIVE' AND r.deleted_at IS NULL
|
||||||
|
WHERE ur.tenant_id = ? AND ur.user_id = ? ORDER BY r.code`,
|
||||||
|
[tenantId, userId]
|
||||||
|
);
|
||||||
|
const [permissions] = await this.pool.execute<CodeRow[]>(
|
||||||
|
`SELECT DISTINCT p.code FROM qipai_user_roles ur
|
||||||
|
INNER JOIN qipai_roles r
|
||||||
|
ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||||
|
AND r.status = 'ACTIVE' AND r.deleted_at IS NULL
|
||||||
|
INNER JOIN qipai_role_permissions rp
|
||||||
|
ON rp.tenant_id = ur.tenant_id AND rp.role_id = ur.role_id
|
||||||
|
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE ur.tenant_id = ? AND ur.user_id = ? ORDER BY p.code`,
|
||||||
|
[tenantId, userId]
|
||||||
|
);
|
||||||
|
const [stores] = await this.pool.execute<StoreRow[]>(
|
||||||
|
`SELECT DISTINCT store_id AS storeId FROM qipai_user_store_scopes
|
||||||
|
WHERE tenant_id = ? AND user_id = ? ORDER BY store_id`,
|
||||||
|
[tenantId, userId]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
roles: roles.map((row) => row.code),
|
||||||
|
capabilities: permissions.map((row) => row.code),
|
||||||
|
storeIds: stores.map((row) => String(row.storeId))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async grantStore(input: {
|
||||||
|
tenantId: string; userId: string; storeId: string; scopeType: 'STAFF' | 'CLEANER';
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||||
|
`INSERT IGNORE INTO qipai_user_store_scopes
|
||||||
|
(tenant_id, user_id, store_id, scope_type)
|
||||||
|
SELECT ?, ?, s.id, ?
|
||||||
|
FROM qipai_stores s
|
||||||
|
INNER JOIN qipai_users u ON u.id = ? AND u.tenant_id = ?
|
||||||
|
WHERE s.id = ? AND s.tenant_id = ? AND s.deleted_at IS NULL`,
|
||||||
|
[
|
||||||
|
input.tenantId, input.userId, input.scopeType,
|
||||||
|
input.userId, input.tenantId, input.storeId, input.tenantId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.affectedRows === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,15 +24,18 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026061601_m01b_core_schema.up.sql',
|
'database/migrations/2026061601_m01b_core_schema.up.sql',
|
||||||
'database/migrations/2026061802_m01c_async_tasks.up.sql',
|
'database/migrations/2026061802_m01c_async_tasks.up.sql',
|
||||||
'database/migrations/2026061803_m02a_tenant_apps.up.sql',
|
'database/migrations/2026061803_m02a_tenant_apps.up.sql',
|
||||||
'database/migrations/2026061804_m02b_wechat_auth.up.sql'
|
'database/migrations/2026061804_m02b_wechat_auth.up.sql',
|
||||||
|
'database/migrations/2026061805_m02c_rbac.up.sql'
|
||||||
],
|
],
|
||||||
verify: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
'database/migrations/2026061802_m01c_async_tasks.verify.sql',
|
'database/migrations/2026061802_m01c_async_tasks.verify.sql',
|
||||||
'database/migrations/2026061803_m02a_tenant_apps.verify.sql',
|
'database/migrations/2026061803_m02a_tenant_apps.verify.sql',
|
||||||
'database/migrations/2026061804_m02b_wechat_auth.verify.sql'
|
'database/migrations/2026061804_m02b_wechat_auth.verify.sql',
|
||||||
|
'database/migrations/2026061805_m02c_rbac.verify.sql'
|
||||||
],
|
],
|
||||||
down: [
|
down: [
|
||||||
|
'database/migrations/2026061805_m02c_rbac.down.sql',
|
||||||
'database/migrations/2026061804_m02b_wechat_auth.down.sql',
|
'database/migrations/2026061804_m02b_wechat_auth.down.sql',
|
||||||
'database/migrations/2026061803_m02a_tenant_apps.down.sql',
|
'database/migrations/2026061803_m02a_tenant_apps.down.sql',
|
||||||
'database/migrations/2026061802_m01c_async_tasks.down.sql',
|
'database/migrations/2026061802_m01c_async_tasks.down.sql',
|
||||||
@@ -152,7 +155,7 @@ export async function executeMigrationPlan(
|
|||||||
for (const [index, statement] of plan.statements.entries()) {
|
for (const [index, statement] of plan.statements.entries()) {
|
||||||
const [result] = await pool.query(statement);
|
const [result] = await pool.query(statement);
|
||||||
if (plan.direction === 'verify') {
|
if (plan.direction === 'verify') {
|
||||||
const minimumRows = [10, 26, 1, 2, 5, 1, 3, 5, 1, 3, 7, 1][index] ?? 1;
|
const minimumRows = [10, 26, 1, 2, 5, 1, 3, 5, 1, 3, 7, 1, 5, 3, 7, 1][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.`
|
`Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
import { signAccessToken, verifyAccessToken } from '../auth/jwt.js';
|
import { signAccessToken, verifyAccessToken } from '../auth/jwt.js';
|
||||||
import { WechatApiError, type WechatCodeExchange } from '../auth/wechat-client.js';
|
import { WechatApiError, type WechatCodeExchange } from '../auth/wechat-client.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
|
||||||
const headersSchema = z.object({
|
const headersSchema = z.object({
|
||||||
'x-wechat-appid': z.string().trim().min(6).max(64),
|
'x-wechat-appid': z.string().trim().min(6).max(64),
|
||||||
@@ -17,6 +18,9 @@ export interface AuthRouteOptions {
|
|||||||
jwtSecret: string;
|
jwtSecret: string;
|
||||||
accessTokenTtlSeconds: number;
|
accessTokenTtlSeconds: number;
|
||||||
sessionTtlSeconds: number;
|
sessionTtlSeconds: number;
|
||||||
|
accessControl?: {
|
||||||
|
getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAuthRoutes(app: FastifyInstance, options: AuthRouteOptions): Promise<void> {
|
export async function registerAuthRoutes(app: FastifyInstance, options: AuthRouteOptions): Promise<void> {
|
||||||
@@ -98,7 +102,14 @@ export async function registerAuthRoutes(app: FastifyInstance, options: AuthRout
|
|||||||
app.get('/app-api/auth/me', async (request, reply) => {
|
app.get('/app-api/auth/me', async (request, reply) => {
|
||||||
const auth = await authenticate(request.headers.authorization, options);
|
const auth = await authenticate(request.headers.authorization, options);
|
||||||
if (!auth) return unauthorized(reply, request.traceId);
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
return { code: 0, data: { user: publicUser(auth.user) }, traceId: request.traceId };
|
const access = options.accessControl
|
||||||
|
? await options.accessControl.getAccessProfile(auth.user.tenantId, auth.user.id)
|
||||||
|
: { roles: [], capabilities: [], storeIds: [] };
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: { user: publicUser(auth.user), access },
|
||||||
|
traceId: request.traceId
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/app-api/auth/logout', async (request, reply) => {
|
app.post('/app-api/auth/logout', async (request, reply) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { loadConfig } from './config.js';
|
|||||||
import { closeMySqlPool, createMySqlPool } from './db/mysql.js';
|
import { closeMySqlPool, createMySqlPool } from './db/mysql.js';
|
||||||
import { PlatformConfigRepository } from './tenancy/platform-config-repository.js';
|
import { PlatformConfigRepository } from './tenancy/platform-config-repository.js';
|
||||||
import { AuthRepository } from './auth/auth-repository.js';
|
import { AuthRepository } from './auth/auth-repository.js';
|
||||||
|
import { RbacRepository } from './auth/rbac-repository.js';
|
||||||
import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js';
|
import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -15,7 +16,8 @@ const app = await buildApp({
|
|||||||
wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)),
|
wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)),
|
||||||
jwtSecret: config.auth.jwtSecret,
|
jwtSecret: config.auth.jwtSecret,
|
||||||
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
|
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
|
||||||
sessionTtlSeconds: config.auth.sessionTtlSeconds
|
sessionTtlSeconds: config.auth.sessionTtlSeconds,
|
||||||
|
accessControl: new RbacRepository(pool)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -90,7 +90,16 @@ const auth = {
|
|||||||
},
|
},
|
||||||
jwtSecret: secret,
|
jwtSecret: secret,
|
||||||
accessTokenTtlSeconds: 900,
|
accessTokenTtlSeconds: 900,
|
||||||
sessionTtlSeconds: 604800
|
sessionTtlSeconds: 604800,
|
||||||
|
accessControl: {
|
||||||
|
async getAccessProfile() {
|
||||||
|
return {
|
||||||
|
roles: ['CUSTOMER'],
|
||||||
|
capabilities: ['order.self.read', 'profile.read'],
|
||||||
|
storeIds: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = await buildApp({ auth });
|
const app = await buildApp({ auth });
|
||||||
@@ -114,6 +123,7 @@ const me = await app.inject({
|
|||||||
});
|
});
|
||||||
assert.equal(me.statusCode, 200);
|
assert.equal(me.statusCode, 200);
|
||||||
assert.equal(me.json().data.user.id, '21');
|
assert.equal(me.json().data.user.id, '21');
|
||||||
|
assert.deepEqual(me.json().data.access.roles, ['CUSTOMER']);
|
||||||
|
|
||||||
const logout = await app.inject({
|
const logout = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ const tenantAppsVerifySql = read('database/migrations/2026061803_m02a_tenant_app
|
|||||||
const authUpSql = read('database/migrations/2026061804_m02b_wechat_auth.up.sql');
|
const authUpSql = read('database/migrations/2026061804_m02b_wechat_auth.up.sql');
|
||||||
const authDownSql = read('database/migrations/2026061804_m02b_wechat_auth.down.sql');
|
const authDownSql = read('database/migrations/2026061804_m02b_wechat_auth.down.sql');
|
||||||
const authVerifySql = read('database/migrations/2026061804_m02b_wechat_auth.verify.sql');
|
const authVerifySql = read('database/migrations/2026061804_m02b_wechat_auth.verify.sql');
|
||||||
|
const rbacUpSql = read('database/migrations/2026061805_m02c_rbac.up.sql');
|
||||||
|
const rbacDownSql = read('database/migrations/2026061805_m02c_rbac.down.sql');
|
||||||
|
const rbacVerifySql = read('database/migrations/2026061805_m02c_rbac.verify.sql');
|
||||||
|
|
||||||
const coreTables = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -112,4 +115,14 @@ assert.match(authUpSql, /UNIQUE KEY uq_qipai_user_identities_tenant_app_openid/)
|
|||||||
assert.match(authUpSql, /revoked_at DATETIME\(3\)/);
|
assert.match(authUpSql, /revoked_at DATETIME\(3\)/);
|
||||||
assert.match(authUpSql, /expires_at DATETIME\(3\)/);
|
assert.match(authUpSql, /expires_at DATETIME\(3\)/);
|
||||||
|
|
||||||
console.log('PASS: M01-B through M02-B migration contracts are present.');
|
for (const table of [
|
||||||
|
'qipai_permissions', 'qipai_roles', 'qipai_role_permissions',
|
||||||
|
'qipai_user_roles', 'qipai_user_store_scopes'
|
||||||
|
]) {
|
||||||
|
assert.match(rbacUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
|
||||||
|
assert.match(rbacDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
|
||||||
|
assert.match(rbacVerifySql, new RegExp(`'${table}'`));
|
||||||
|
}
|
||||||
|
assert.match(rbacUpSql, /PRIMARY KEY \(tenant_id, user_id, store_id, scope_type\)/);
|
||||||
|
|
||||||
|
console.log('PASS: M01-B through M02-C migration contracts are present.');
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ assert.equal(plan.direction, 'up');
|
|||||||
assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql/);
|
assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql/);
|
||||||
assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql/);
|
assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql/);
|
||||||
assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql/);
|
assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql/);
|
||||||
assert.match(plan.file, /2026061804_m02b_wechat_auth\.up\.sql$/);
|
assert.match(plan.file, /2026061804_m02b_wechat_auth\.up\.sql/);
|
||||||
|
assert.match(plan.file, /2026061805_m02c_rbac\.up\.sql$/);
|
||||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PlatformConfigRepository
|
PlatformConfigRepository
|
||||||
} from '../dist/tenancy/platform-config-repository.js';
|
} from '../dist/tenancy/platform-config-repository.js';
|
||||||
import { AuthRepository } from '../dist/auth/auth-repository.js';
|
import { AuthRepository } from '../dist/auth/auth-repository.js';
|
||||||
|
import { RbacRepository } from '../dist/auth/rbac-repository.js';
|
||||||
import {
|
import {
|
||||||
executeMigrationPlan,
|
executeMigrationPlan,
|
||||||
loadMigrationPlan,
|
loadMigrationPlan,
|
||||||
@@ -27,7 +28,10 @@ const expectedTables = [
|
|||||||
'qipai_orders',
|
'qipai_orders',
|
||||||
'qipai_outbox_events',
|
'qipai_outbox_events',
|
||||||
'qipai_payments',
|
'qipai_payments',
|
||||||
|
'qipai_permissions',
|
||||||
'qipai_platform_apps',
|
'qipai_platform_apps',
|
||||||
|
'qipai_role_permissions',
|
||||||
|
'qipai_roles',
|
||||||
'qipai_rooms',
|
'qipai_rooms',
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
'qipai_stores',
|
'qipai_stores',
|
||||||
@@ -35,6 +39,8 @@ const expectedTables = [
|
|||||||
'qipai_tenant_configs',
|
'qipai_tenant_configs',
|
||||||
'qipai_tenants',
|
'qipai_tenants',
|
||||||
'qipai_user_identities',
|
'qipai_user_identities',
|
||||||
|
'qipai_user_roles',
|
||||||
|
'qipai_user_store_scopes',
|
||||||
'qipai_users'
|
'qipai_users'
|
||||||
];
|
];
|
||||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
@@ -56,9 +62,9 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804']
|
['2026061601', '2026061802', '2026061803', '2026061804', '2026061805']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -193,6 +199,28 @@ async function assertRevocableAuthSession(pool, context) {
|
|||||||
userAgent: 'M02-B test'
|
userAgent: 'M02-B test'
|
||||||
});
|
});
|
||||||
assert.equal(session.user.userType, 'CUSTOMER');
|
assert.equal(session.user.userType, 'CUSTOMER');
|
||||||
|
const rbac = new RbacRepository(pool);
|
||||||
|
assert.deepEqual(await rbac.getAccessProfile(context.tenantId, session.user.id), {
|
||||||
|
roles: ['CUSTOMER'],
|
||||||
|
capabilities: ['order.self.read', 'profile.read'],
|
||||||
|
storeIds: []
|
||||||
|
});
|
||||||
|
const [storeResult] = await pool.query(
|
||||||
|
`INSERT INTO qipai_stores (tenant_id, name) VALUES (?, 'M02C Store')`,
|
||||||
|
[context.tenantId]
|
||||||
|
);
|
||||||
|
assert.equal(await rbac.grantStore({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
userId: session.user.id,
|
||||||
|
storeId: String(storeResult.insertId),
|
||||||
|
scopeType: 'STAFF'
|
||||||
|
}), true);
|
||||||
|
assert.equal(await rbac.grantStore({
|
||||||
|
tenantId: String(Number(context.tenantId) + 1),
|
||||||
|
userId: session.user.id,
|
||||||
|
storeId: String(storeResult.insertId),
|
||||||
|
scopeType: 'STAFF'
|
||||||
|
}), false);
|
||||||
assert.equal((await repository.loginWithWechat({
|
assert.equal((await repository.loginWithWechat({
|
||||||
context,
|
context,
|
||||||
openid: 'm02b-openid-a',
|
openid: 'm02b-openid-a',
|
||||||
@@ -249,7 +277,8 @@ try {
|
|||||||
{ version: '2026061601', name: 'm01b_core_schema' },
|
{ version: '2026061601', name: 'm01b_core_schema' },
|
||||||
{ version: '2026061802', name: 'm01c_async_tasks' },
|
{ version: '2026061802', name: 'm01c_async_tasks' },
|
||||||
{ version: '2026061803', name: 'm02a_tenant_apps' },
|
{ version: '2026061803', name: 'm02a_tenant_apps' },
|
||||||
{ version: '2026061804', name: 'm02b_wechat_auth' }
|
{ version: '2026061804', name: 'm02b_wechat_auth' },
|
||||||
|
{ version: '2026061805', name: 'm02c_rbac' }
|
||||||
]);
|
]);
|
||||||
await assertTaskDurability(pool);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -269,7 +298,8 @@ try {
|
|||||||
{ version: '2026061601', name: 'm01b_core_schema' },
|
{ version: '2026061601', name: 'm01b_core_schema' },
|
||||||
{ version: '2026061802', name: 'm01c_async_tasks' },
|
{ version: '2026061802', name: 'm01c_async_tasks' },
|
||||||
{ version: '2026061803', name: 'm02a_tenant_apps' },
|
{ version: '2026061803', name: 'm02a_tenant_apps' },
|
||||||
{ version: '2026061804', name: 'm02b_wechat_auth' }
|
{ version: '2026061804', name: 'm02b_wechat_auth' },
|
||||||
|
{ version: '2026061805', name: 'm02c_rbac' }
|
||||||
]);
|
]);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
@@ -300,7 +330,9 @@ try {
|
|||||||
'cross-tenant bootstrap rejection',
|
'cross-tenant bootstrap rejection',
|
||||||
'openid identity reuse',
|
'openid identity reuse',
|
||||||
'session revocation',
|
'session revocation',
|
||||||
'role-version invalidation'
|
'role-version invalidation',
|
||||||
|
'customer capabilities',
|
||||||
|
'cross-tenant store grant rejection'
|
||||||
]
|
]
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { RbacRepository, roleCodes } from '../dist/auth/rbac-repository.js';
|
||||||
|
|
||||||
|
assert.deepEqual(roleCodes, [
|
||||||
|
'CUSTOMER', 'CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
const repository = new RbacRepository({
|
||||||
|
async execute(sql, params) {
|
||||||
|
calls.push([sql, params]);
|
||||||
|
if (sql.includes('SELECT DISTINCT r.code')) return [[{ code: 'CUSTOMER' }], []];
|
||||||
|
if (sql.includes('SELECT DISTINCT p.code')) {
|
||||||
|
return [[{ code: 'order.self.read' }, { code: 'profile.read' }], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT DISTINCT store_id')) return [[{ storeId: 11 }], []];
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.deepEqual(await repository.getAccessProfile('7', '21'), {
|
||||||
|
roles: ['CUSTOMER'],
|
||||||
|
capabilities: ['order.self.read', 'profile.read'],
|
||||||
|
storeIds: ['11']
|
||||||
|
});
|
||||||
|
assert.equal(await repository.grantStore({
|
||||||
|
tenantId: '7', userId: '21', storeId: '11', scopeType: 'STAFF'
|
||||||
|
}), true);
|
||||||
|
assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
|
||||||
|
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
|
||||||
|
|
||||||
|
console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.');
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DELETE FROM qipai_schema_migrations WHERE version = '2026061805';
|
||||||
|
DROP TABLE IF EXISTS qipai_user_store_scopes;
|
||||||
|
DROP TABLE IF EXISTS qipai_user_roles;
|
||||||
|
DROP TABLE IF EXISTS qipai_role_permissions;
|
||||||
|
DROP TABLE IF EXISTS qipai_roles;
|
||||||
|
DROP TABLE IF EXISTS qipai_permissions;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS qipai_permissions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(128) NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
category VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE KEY uq_qipai_permissions_code (code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_roles (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
code VARCHAR(32) NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
role_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
deleted_at DATETIME(3) NULL,
|
||||||
|
CONSTRAINT fk_qipai_roles_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||||
|
UNIQUE KEY uq_qipai_roles_tenant_code (tenant_id, code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_role_permissions (
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
role_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
permission_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (tenant_id, role_id, permission_id),
|
||||||
|
CONSTRAINT fk_qipai_role_permissions_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_role_permissions_role FOREIGN KEY (role_id) REFERENCES qipai_roles(id),
|
||||||
|
CONSTRAINT fk_qipai_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES qipai_permissions(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_user_roles (
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
role_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (tenant_id, user_id, role_id),
|
||||||
|
CONSTRAINT fk_qipai_user_roles_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_user_roles_user FOREIGN KEY (user_id) REFERENCES qipai_users(id),
|
||||||
|
CONSTRAINT fk_qipai_user_roles_role FOREIGN KEY (role_id) REFERENCES qipai_roles(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_user_store_scopes (
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
store_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
scope_type VARCHAR(32) NOT NULL DEFAULT 'STAFF',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (tenant_id, user_id, store_id, scope_type),
|
||||||
|
CONSTRAINT fk_qipai_user_store_scopes_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_user_store_scopes_user FOREIGN KEY (user_id) REFERENCES qipai_users(id),
|
||||||
|
CONSTRAINT fk_qipai_user_store_scopes_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||||
|
KEY idx_qipai_user_store_scopes_store (tenant_id, store_id, scope_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
|
||||||
|
('profile.read', '查看个人资料', 'account'),
|
||||||
|
('order.self.read', '查看本人订单', 'order'),
|
||||||
|
('cleaning.task.read', '查看保洁任务', 'cleaning'),
|
||||||
|
('store.operation.read', '查看门店运营', 'store'),
|
||||||
|
('store.operation.write', '管理门店运营', 'store'),
|
||||||
|
('tenant.manage', '管理租户', 'tenant'),
|
||||||
|
('platform.manage', '管理平台', 'platform');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||||
|
VALUES ('2026061805', 'm02c_rbac');
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name IN ('qipai_permissions', 'qipai_roles', 'qipai_role_permissions',
|
||||||
|
'qipai_user_roles', 'qipai_user_store_scopes')
|
||||||
|
ORDER BY table_name;
|
||||||
|
|
||||||
|
SELECT table_name, index_name FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND ((table_name = 'qipai_permissions' AND index_name = 'uq_qipai_permissions_code')
|
||||||
|
OR (table_name = 'qipai_roles' AND index_name = 'uq_qipai_roles_tenant_code')
|
||||||
|
OR (table_name = 'qipai_user_store_scopes' AND index_name = 'idx_qipai_user_store_scopes_store'))
|
||||||
|
GROUP BY table_name, index_name ORDER BY table_name, index_name;
|
||||||
|
|
||||||
|
SELECT code FROM qipai_permissions
|
||||||
|
WHERE code IN ('profile.read', 'order.self.read', 'cleaning.task.read',
|
||||||
|
'store.operation.read', 'store.operation.write', 'tenant.manage', 'platform.manage')
|
||||||
|
ORDER BY code;
|
||||||
|
|
||||||
|
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026061805';
|
||||||
@@ -8,6 +8,7 @@ $requiredFiles = @(
|
|||||||
"backend/src/app.ts",
|
"backend/src/app.ts",
|
||||||
"backend/src/auth/auth-repository.ts",
|
"backend/src/auth/auth-repository.ts",
|
||||||
"backend/src/auth/jwt.ts",
|
"backend/src/auth/jwt.ts",
|
||||||
|
"backend/src/auth/rbac-repository.ts",
|
||||||
"backend/src/auth/wechat-client.ts",
|
"backend/src/auth/wechat-client.ts",
|
||||||
"backend/src/config.ts",
|
"backend/src/config.ts",
|
||||||
"backend/src/db/mysql.ts",
|
"backend/src/db/mysql.ts",
|
||||||
@@ -31,6 +32,7 @@ $requiredFiles = @(
|
|||||||
"backend/tests/task-repository.test.mjs",
|
"backend/tests/task-repository.test.mjs",
|
||||||
"backend/tests/platform-config-repository.test.mjs",
|
"backend/tests/platform-config-repository.test.mjs",
|
||||||
"backend/tests/auth.test.mjs",
|
"backend/tests/auth.test.mjs",
|
||||||
|
"backend/tests/rbac.test.mjs",
|
||||||
"scripts/dev/wsl/mysql-migration-roundtrip.sh",
|
"scripts/dev/wsl/mysql-migration-roundtrip.sh",
|
||||||
"database/migrations/2026061601_m01b_core_schema.up.sql",
|
"database/migrations/2026061601_m01b_core_schema.up.sql",
|
||||||
"database/migrations/2026061601_m01b_core_schema.down.sql",
|
"database/migrations/2026061601_m01b_core_schema.down.sql",
|
||||||
@@ -44,6 +46,9 @@ $requiredFiles = @(
|
|||||||
"database/migrations/2026061804_m02b_wechat_auth.up.sql",
|
"database/migrations/2026061804_m02b_wechat_auth.up.sql",
|
||||||
"database/migrations/2026061804_m02b_wechat_auth.down.sql",
|
"database/migrations/2026061804_m02b_wechat_auth.down.sql",
|
||||||
"database/migrations/2026061804_m02b_wechat_auth.verify.sql",
|
"database/migrations/2026061804_m02b_wechat_auth.verify.sql",
|
||||||
|
"database/migrations/2026061805_m02c_rbac.up.sql",
|
||||||
|
"database/migrations/2026061805_m02c_rbac.down.sql",
|
||||||
|
"database/migrations/2026061805_m02c_rbac.verify.sql",
|
||||||
"database/seeds/2026061601_m01b_minimal_seed.sql",
|
"database/seeds/2026061601_m01b_minimal_seed.sql",
|
||||||
"deploy/pm2/ecosystem.config.cjs"
|
"deploy/pm2/ecosystem.config.cjs"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
|
|||||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
||||||
|
|
||||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M02-B migration roundtrip in a temporary database."
|
echo "INFO: MySQL ${mysql_version}; running M01-B through M02-C migration roundtrip in a temporary database."
|
||||||
npm --prefix backend run test:mysql:migration
|
npm --prefix backend run test:mysql:migration
|
||||||
echo "PASS: M01-B through M02-B live MySQL migration roundtrip completed."
|
echo "PASS: M01-B through M02-C live MySQL migration roundtrip completed."
|
||||||
|
|||||||
Reference in New Issue
Block a user