feat(M02-C): 建立RBAC与门店数据范围

This commit is contained in:
Codex
2026-06-18 10:50:43 +08:00
parent a8c2a3b3e1
commit caacc78545
16 changed files with 334 additions and 16 deletions
+11 -1
View File
@@ -90,7 +90,16 @@ const auth = {
},
jwtSecret: secret,
accessTokenTtlSeconds: 900,
sessionTtlSeconds: 604800
sessionTtlSeconds: 604800,
accessControl: {
async getAccessProfile() {
return {
roles: ['CUSTOMER'],
capabilities: ['order.self.read', 'profile.read'],
storeIds: []
};
}
}
};
const app = await buildApp({ auth });
@@ -114,6 +123,7 @@ const me = await app.inject({
});
assert.equal(me.statusCode, 200);
assert.equal(me.json().data.user.id, '21');
assert.deepEqual(me.json().data.access.roles, ['CUSTOMER']);
const logout = await app.inject({
method: 'POST',
+14 -1
View File
@@ -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 authDownSql = read('database/migrations/2026061804_m02b_wechat_auth.down.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 = [
'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, /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.');
+2 -1
View File
@@ -15,7 +15,8 @@ assert.equal(plan.direction, 'up');
assert.match(plan.file, /2026061601_m01b_core_schema\.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, /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.ok(plan.statements.length >= 11);
@@ -11,6 +11,7 @@ import {
PlatformConfigRepository
} from '../dist/tenancy/platform-config-repository.js';
import { AuthRepository } from '../dist/auth/auth-repository.js';
import { RbacRepository } from '../dist/auth/rbac-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -27,7 +28,10 @@ const expectedTables = [
'qipai_orders',
'qipai_outbox_events',
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_role_permissions',
'qipai_roles',
'qipai_rooms',
'qipai_schema_migrations',
'qipai_stores',
@@ -35,6 +39,8 @@ const expectedTables = [
'qipai_tenant_configs',
'qipai_tenants',
'qipai_user_identities',
'qipai_user_roles',
'qipai_user_store_scopes',
'qipai_users'
];
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
@@ -56,9 +62,9 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804']
['2026061601', '2026061802', '2026061803', '2026061804', '2026061805']
);
return rows;
}
@@ -193,6 +199,28 @@ async function assertRevocableAuthSession(pool, context) {
userAgent: 'M02-B test'
});
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({
context,
openid: 'm02b-openid-a',
@@ -249,7 +277,8 @@ try {
{ version: '2026061601', name: 'm01b_core_schema' },
{ version: '2026061802', name: 'm01c_async_tasks' },
{ 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);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -269,7 +298,8 @@ try {
{ version: '2026061601', name: 'm01b_core_schema' },
{ version: '2026061802', name: 'm01c_async_tasks' },
{ 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);
console.log('PASS: second up and verify restored the schema.');
@@ -300,7 +330,9 @@ try {
'cross-tenant bootstrap rejection',
'openid identity reuse',
'session revocation',
'role-version invalidation'
'role-version invalidation',
'customer capabilities',
'cross-tenant store grant rejection'
]
}, null, 2));
} finally {
+31
View File
@@ -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.');