feat(identity): 完成 A2 角色权限与数据范围全栈落地

背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。

改动:
- shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。
- prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。
- api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。
- web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。
- 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。

验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
This commit is contained in:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

@@ -19,11 +19,18 @@ interface FixtureUser {
account: string;
}
interface MeBody {
permissions: string[];
dataScopes: { type: string; customerIds?: string[] }[];
user: { id: string; phone?: string };
mustChangePassword: boolean;
}
interface TokenBody {
accessToken: string;
refreshToken: string;
expiresIn: number;
me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean };
me: MeBody;
}
interface ErrorBody {
@@ -88,6 +95,87 @@ async function setRole(userId: string, roleCode: string): Promise<void> {
await prisma.userRole.create({ data: { userId, roleId: role.id } });
}
interface JsonRequestResult<T> {
status: number;
body: T;
}
async function callJson<T>(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<JsonRequestResult<T>> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return { status: response.statusCode, body: response.json<T>() };
}
const ROLE_NAME_PREFIX = 'IT-角色-';
/** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */
const createdRoleIds: string[] = [];
/** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */
async function createTestRole(input: {
name: string;
permissions?: string[];
isSystem?: boolean;
dataScope?: { type: string; targetIds?: string[] };
status?: 'ACTIVE' | 'DISABLED';
}): Promise<{ id: string; name: string }> {
const id = ulid();
const role = await prisma.role.create({
data: {
id,
code: `it_role_${id.toLowerCase()}`,
name: `${ROLE_NAME_PREFIX}${input.name}`,
description: '集成测试角色',
isSystem: input.isSystem ?? false,
status: input.status ?? 'ACTIVE',
permissions: {
create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })),
},
dataScopes: {
create: [
{
id: ulid(),
scopeType: input.dataScope?.type ?? 'created_by_me',
targetId: input.dataScope?.targetIds?.[0] ?? null,
},
],
},
},
});
createdRoleIds.push(role.id);
return { id: role.id, name: role.name };
}
async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise<FixtureUser> {
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
const account = `${ACCOUNT_PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: `138${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId }] },
},
});
return { id, account };
}
describe('identity 集成(真实 PG / Redis)', () => {
let created: CreatedApiApp;
let manager: FixtureUser;
@@ -98,12 +186,17 @@ describe('identity 集成(真实 PG / Redis)', () => {
created = await createApp();
await created.app.init();
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
manager = await createFixtureUser('manager', 'platform_admin');
viewer = await createFixtureUser('viewer', 'designer');
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
if (createdRoleIds.length > 0) {
await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } });
}
await prisma.$disconnect();
await created.app.close();
});
@@ -235,4 +328,316 @@ describe('identity 集成(真实 PG / Redis)', () => {
expect(roles.statusCode).toBe(200);
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
});
// -------------------------------------------------------------------------
// A2:角色权限、权限树、SoD、数据范围、停用即时生效
// -------------------------------------------------------------------------
it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => {
const { body } = await login(created.app, manager.account);
const response = await callJson<{
modules: { module: string; moduleLabel: string }[];
specialPermissions: { code: string }[];
sodPairs: { id: string }[];
totalPermissions: number;
}>(created.app, 'GET', '/permissions', body.accessToken);
expect(response.status).toBe(200);
expect(response.body.totalPermissions).toBe(30);
expect(response.body.modules.map((module) => module.module)).toEqual([
'script',
'material',
'asset',
'delivery',
'customer',
'customer_group',
'identity',
'industry',
'platform',
'audit',
]);
expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组');
expect(response.body.specialPermissions).toHaveLength(6);
expect(response.body.sodPairs).toHaveLength(3);
// 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响)
const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } });
const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id);
const designerLogin = await login(created.app, designerOnly.account);
const denied = await callJson<{ code: number }>(
created.app,
'GET',
'/permissions',
designerLogin.body.accessToken,
);
expect(denied.status).toBe(403);
expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED);
});
it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => {
const { body } = await login(created.app, manager.account);
const response = await callJson<{
items: {
id: string;
code: string;
name: string;
isSystem: boolean;
permissionCount: number;
specialPermissionCount: number;
userCount: number;
dataScope: { type: string };
}[];
}>(created.app, 'GET', '/roles', body.accessToken);
expect(response.status).toBe(200);
const codes = response.body.items.map((item) => item.code);
expect(codes).not.toContain('review_lead');
expect(codes).toContain('material_operator');
const superAdmin = response.body.items.find((item) => item.code === 'super_admin');
expect(superAdmin?.isSystem).toBe(true);
expect(superAdmin?.permissionCount).toBe(24);
expect(superAdmin?.specialPermissionCount).toBe(6);
expect(superAdmin?.dataScope).toEqual({ type: 'all' });
const all = await callJson<{ items: { code: string }[] }>(
created.app,
'GET',
'/roles?status=all',
body.accessToken,
);
expect(all.body.items.map((item) => item.code)).toContain('review_lead');
// A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选)
const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] });
const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id);
const selectorLogin = await login(created.app, selector.account);
const selectorRoles = await callJson<{ items: { code: string }[] }>(
created.app,
'GET',
'/roles',
selectorLogin.body.accessToken,
);
expect(selectorRoles.status).toBe(200);
expect(selectorRoles.body.items.length).toBeGreaterThan(0);
const selectorPermissions = await callJson<{ code: number }>(
created.app,
'GET',
'/permissions',
selectorLogin.body.accessToken,
);
expect(selectorPermissions.status).toBe(403);
});
it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => {
const { body } = await login(created.app, manager.account);
const createdRole = await callJson<{
role: {
id: string;
code: string;
status: string;
permissions: string[];
specialPermissions: string[];
dataScope: { type: string };
boundUsers: unknown[];
};
affectedUserCount: number;
warnings: string[];
}>(created.app, 'POST', '/roles', body.accessToken, {
name: `${ROLE_NAME_PREFIX}内容运营`,
permissions: ['script:read'],
dataScope: { type: 'department' },
});
expect(createdRole.status).toBe(201);
createdRoleIds.push(createdRole.body.role.id);
expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/);
expect(createdRole.body.role.permissions).toEqual(['script:read']);
expect(createdRole.body.role.specialPermissions).toEqual([]);
expect(createdRole.body.role.dataScope).toEqual({ type: 'department' });
expect(createdRole.body.affectedUserCount).toBe(0);
const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>(
created.app,
'POST',
'/roles',
body.accessToken,
{
name: `${ROLE_NAME_PREFIX}互斥冲突`,
permissions: ['script:write', 'script:review'],
dataScope: { type: 'all' },
},
);
expect(sod.status).toBe(403);
expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION);
expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review');
const specialInNormal = await callJson<{ code: number }>(
created.app,
'POST',
'/roles',
body.accessToken,
{
name: `${ROLE_NAME_PREFIX}特殊权限错位`,
permissions: ['delivery:export'],
dataScope: { type: 'all' },
},
);
expect(specialInNormal.status).toBe(400);
expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID);
});
it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => {
const { body } = await login(created.app, manager.account);
const role = await createTestRole({
name: '待编辑',
permissions: ['script:read'],
dataScope: { type: 'department' },
});
const boundUser = await createFixtureUserWithRoleId('role-edit', role.id);
const patched = await callJson<{
role: {
permissions: string[];
specialPermissions: string[];
dataScope: { type: string };
status: string;
};
affectedUserCount: number;
warnings: string[];
}>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, {
permissions: ['script:read', 'user:view'],
specialPermissions: ['delivery:export'],
dataScope: { type: 'owned_customers' },
});
expect(patched.status).toBe(200);
expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']);
expect(patched.body.role.specialPermissions).toEqual(['delivery:export']);
expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' });
expect(patched.body.affectedUserCount).toBe(1);
const outbox = await prisma.outboxEvent.findFirst({
where: { aggregateId: role.id, eventType: 'role.permissions.changed' },
orderBy: { occurredAt: 'desc' },
});
expect(outbox).not.toBeNull();
const payload = outbox?.payloadJson as {
roleId: string;
affectedUserIds: string[];
changes: { permissions: { added: string[]; removed: string[] } };
};
expect(payload.roleId).toBe(role.id);
expect(payload.affectedUserIds).toEqual([boundUser.id]);
expect(payload.changes.permissions.added).toEqual(['user:view']);
// 系统角色保护:改权限 40903,仅显示名可改
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
const protectedUpdate = await callJson<{ code: number }>(
created.app,
'PATCH',
`/roles/${superAdminRole.id}`,
body.accessToken,
{ permissions: ['user:view'] },
);
expect(protectedUpdate.status).toBe(409);
expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const renamed = await callJson<{ role: { name: string } }>(
created.app,
'PATCH',
`/roles/${superAdminRole.id}`,
body.accessToken,
{ name: '超级管理员' },
);
expect(renamed.status).toBe(200);
expect(renamed.body.role.name).toBe('超级管理员');
});
it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => {
const { body } = await login(created.app, manager.account);
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
const systemDelete = await callJson<{ code: number }>(
created.app,
'DELETE',
`/roles/${superAdminRole.id}`,
body.accessToken,
);
expect(systemDelete.status).toBe(409);
expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const bound = await createTestRole({ name: '有绑定' });
await createFixtureUserWithRoleId('role-bound', bound.id);
const boundDelete = await callJson<{ code: number; details: { userCount: number } }>(
created.app,
'DELETE',
`/roles/${bound.id}`,
body.accessToken,
);
expect(boundDelete.status).toBe(409);
expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
expect(boundDelete.body.details.userCount).toBe(1);
const free = await createTestRole({ name: '无绑定' });
const freeDelete = await created.app.inject({
method: 'DELETE',
url: `${API}/roles/${free.id}`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(freeDelete.statusCode).toBe(204);
const gone = await prisma.role.findUnique({ where: { id: free.id } });
expect(gone).toBeNull();
});
it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => {
const { body } = await login(created.app, manager.account);
const role = await createTestRole({ name: '待停用', permissions: ['user:view'] });
const user = await createFixtureUserWithRoleId('role-disable', role.id);
const userLogin = await login(created.app, user.account);
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>(
created.app,
'PATCH',
`/roles/${role.id}`,
body.accessToken,
{ status: 'disabled', reason: '集成测试停用' },
);
expect(disabled.status).toBe(200);
expect(disabled.body.role.status).toBe('disabled');
expect(disabled.body.affectedUserCount).toBe(1);
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403);
const enabled = await callJson<{ role: { status: string } }>(
created.app,
'PATCH',
`/roles/${role.id}`,
body.accessToken,
{ status: 'enabled' },
);
expect(enabled.body.role.status).toBe('active');
// 再启用后同一 access token 恢复权限(每请求解析,无缓存)
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
});
it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => {
const adminLogin = await login(created.app, manager.account);
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]);
const ownedRole = await createTestRole({
name: '负责客户范围',
permissions: ['user:view'],
dataScope: { type: 'owned_customers' },
});
const deptRole = await createTestRole({
name: '部门范围',
permissions: ['customer:read'],
dataScope: { type: 'department' },
});
const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id);
await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } });
const userLogin = await login(created.app, user.account);
expect(userLogin.status).toBe(200);
const scopes = userLogin.body.me.dataScopes;
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
for (const scope of scopes) {
expect(scope.customerIds).toEqual([]);
}
});
});