diff --git a/web/src/engine/rbac.test.ts b/web/src/engine/rbac.test.ts new file mode 100644 index 00000000..1d940db9 --- /dev/null +++ b/web/src/engine/rbac.test.ts @@ -0,0 +1,253 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_ROLES, + hasPermission, + isDeliverableVisible, + filterVisibleDeliverablesWithRBAC, + canPerformDocAction, +} from './rbac'; +import { UserProfile } from '../types/docgov'; +import { DeliverableItem } from '../types/workbench'; +import { RoleDefinition } from '../types/rbac'; + +const sampleUsers: Record = { + admin: { + id: 100, + name: '系统管理员', + employeeId: 'ADMIN-001', + department: '系统运维部', + position: '系统管理员', + roleId: 'role-admin', + }, + director: { + id: 3, + name: '王总监', + employeeId: 'SMDF-0018', + department: '医疗器械研发事业部', + position: '微创器械研发总监', + roleId: 'role-director', + }, + lead: { + id: 2, + name: '李主管', + employeeId: 'SMDF-0205', + department: '机器人与微创内窥镜研发中心', + position: '研发项目技术主管', + roleId: 'role-tech-lead', + }, + engineer: { + id: 1, + name: '张工', + employeeId: 'SMDF-0821', + department: '机器人与微创内窥镜研发中心', + position: '资深医疗器械方案工程师', + roleId: 'role-engineer', + }, + guest: { + id: 99, + name: '外部审计专员', + employeeId: 'GUEST-001', + department: '第三方认证机构', + position: '外部合规审查员', + roleId: 'role-guest', + }, +}; + +const createMockDoc = (overrides: Partial = {}): DeliverableItem => ({ + docId: 'doc-mock-001', + projectId: 1, + taskKey: 'feasibility', + title: '测试受控交付物', + stage: 'V0.1', + category: 'workflow', + creatorId: 1, + creatorName: '张工', + isFixedWorkflow: true, + isTemplateOnly: false, + lifecycleState: 'drafting', + isClaimed: true, + author: '张工', + reviewer: '李主管', + approver: '王总监', + updatedAt: '2026-09-08 12:00', + ...overrides, +}); + +test('用例 1: DEFAULT_ROLES 包含 5 种默认角色且具备预期权限', () => { + assert.equal(DEFAULT_ROLES.length, 5); + const admin = DEFAULT_ROLES.find((r) => r.code === 'ADMIN'); + const engineer = DEFAULT_ROLES.find((r) => r.code === 'ENGINEER'); + const guest = DEFAULT_ROLES.find((r) => r.code === 'GUEST'); + + assert.ok(admin); + assert.ok(admin.isSystem); + assert.ok(admin.permissions.includes('perm:manage')); + + assert.ok(engineer); + assert.ok(engineer.permissions.includes('workflow:view_all')); + assert.equal(engineer.permissions.includes('project:create'), false); + + assert.ok(guest); + assert.equal(guest.permissions.includes('workflow:view_all'), false); +}); + +test('用例 2: 系统管理员 (ADMIN) 与具备 perm:manage 用户的通配豁免特权', () => { + assert.equal(hasPermission(sampleUsers.admin, DEFAULT_ROLES, 'project:create'), true); + assert.equal(hasPermission(sampleUsers.admin, DEFAULT_ROLES, 'perm:manage'), true); + assert.equal(hasPermission(sampleUsers.admin, DEFAULT_ROLES, 'doc:override_visibility'), true); + + // 普通工程师无管理权限 + assert.equal(hasPermission(sampleUsers.engineer, DEFAULT_ROLES, 'perm:manage'), false); + assert.equal(hasPermission(sampleUsers.engineer, DEFAULT_ROLES, 'project:create'), false); +}); + +test('用例 3: 常规工作流基线可见性 (workflow:view_all) 判定', () => { + const normalDoc = createMockDoc(); + + // 工程师有 workflow:view_all -> 可见 + assert.equal(isDeliverableVisible(sampleUsers.engineer, DEFAULT_ROLES, normalDoc), true); + + // 外部审计员无 workflow:view_all 且不是责任人 -> 不可见 + assert.equal(isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, normalDoc), false); +}); + +test('用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截)', () => { + const restrictedDoc = createMockDoc({ + author: '张工', + reviewer: '李主管', + approver: '赵院长', // 王总监不是责任人 + visibilityRule: { + mode: 'designated_only', + overrideReason: '敏感临床事故报告,收紧仅责任人可见', + configuredBy: '李主管', + configuredAt: '2026-09-08 14:00', + }, + }); + + // 张工是编制人 -> 可见 + assert.equal(isDeliverableVisible(sampleUsers.engineer, DEFAULT_ROLES, restrictedDoc), true); + + // 李主管是审核人 -> 可见 + assert.equal(isDeliverableVisible(sampleUsers.lead, DEFAULT_ROLES, restrictedDoc), true); + + // 王总监虽为研发总监,但非三权责任人 -> 被单文档覆盖拦截 (不可见) + assert.equal(isDeliverableVisible(sampleUsers.director, DEFAULT_ROLES, restrictedDoc), false); + + // 系统管理员超级权限仍可见 + assert.equal(isDeliverableVisible(sampleUsers.admin, DEFAULT_ROLES, restrictedDoc), true); +}); + +test('用例 5: 单文档覆盖 - role_whitelist (仅白名单内角色可见,其他角色被精确拦截)', () => { + const secretPatentDoc = createMockDoc({ + author: '外聘专家', + reviewer: '外聘审查', + approver: '外聘顾问', + visibilityRule: { + mode: 'role_whitelist', + allowedRoleIds: ['role-director'], // 仅总监可见 + overrideReason: '核心专利保密算法拆解', + }, + }); + + // 王总监拥有 role-director -> 可见 + assert.equal(isDeliverableVisible(sampleUsers.director, DEFAULT_ROLES, secretPatentDoc), true); + + // 张工 (role-engineer) 不在白名单中 -> 不可见 + assert.equal(isDeliverableVisible(sampleUsers.engineer, DEFAULT_ROLES, secretPatentDoc), false); + + // 李主管 (role-tech-lead) 不在白名单中 -> 不可见 + assert.equal(isDeliverableVisible(sampleUsers.lead, DEFAULT_ROLES, secretPatentDoc), false); +}); + +test('用例 6: 单文档覆盖 - public (放宽可见性,无 workflow:view_all 权限的访客也能查阅)', () => { + const publicSpecDoc = createMockDoc({ + author: '张工', + reviewer: '李主管', + approver: '王总监', + visibilityRule: { + mode: 'public', + overrideReason: '产品公开白皮书,供外部审计或用户公开查阅', + }, + }); + + // 访客原本无 workflow:view_all,但单文档设为 public -> 可见 + assert.equal(isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, publicSpecDoc), true); +}); + +test('用例 7: 私密文档隔离强红线 (category === private 无论任何管理员或单文档覆盖,非创建者绝对不可见)', () => { + const privateDraft = createMockDoc({ + category: 'private', + creatorId: 1, // 张工创建 + creatorName: '张工', + visibilityRule: { + mode: 'public', // 即使误设 public 也绝不能穿透 private 隔离 + }, + }); + + // 创建者本人可见 + assert.equal(isDeliverableVisible(sampleUsers.engineer, DEFAULT_ROLES, privateDraft), true); + + // 其他人不可见 + assert.equal(isDeliverableVisible(sampleUsers.lead, DEFAULT_ROLES, privateDraft), false); + assert.equal(isDeliverableVisible(sampleUsers.director, DEFAULT_ROLES, privateDraft), false); + assert.equal(isDeliverableVisible(sampleUsers.admin, DEFAULT_ROLES, privateDraft), false); + assert.equal(isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, privateDraft), false); +}); + +test('用例 8: 核心业务动作权限拦截判定 (canPerformDocAction & hasPermission)', () => { + // 1. 创建项目权限 + assert.equal(canPerformDocAction('create_project', sampleUsers.director, DEFAULT_ROLES), true); + assert.equal(canPerformDocAction('create_project', sampleUsers.lead, DEFAULT_ROLES), true); + assert.equal(canPerformDocAction('create_project', sampleUsers.engineer, DEFAULT_ROLES), false); + assert.equal(canPerformDocAction('create_project', sampleUsers.guest, DEFAULT_ROLES), false); + + // 2. 指派文档法定三权责任人 + assert.equal(canPerformDocAction('assign_lead', sampleUsers.director, DEFAULT_ROLES), true); + assert.equal(canPerformDocAction('assign_lead', sampleUsers.lead, DEFAULT_ROLES), true); + assert.equal(canPerformDocAction('assign_lead', sampleUsers.engineer, DEFAULT_ROLES), false); + + // 3. 配置单文档覆盖 + assert.equal(canPerformDocAction('override_visibility', sampleUsers.director, DEFAULT_ROLES), true); + assert.equal(canPerformDocAction('override_visibility', sampleUsers.lead, DEFAULT_ROLES), true); + assert.equal(canPerformDocAction('override_visibility', sampleUsers.engineer, DEFAULT_ROLES), false); + + // 4. 用户个人级特例微调 (customPermissions) 赋予方案工程师特例权限 + const customEngineer: UserProfile = { + ...sampleUsers.engineer, + customPermissions: ['project:create'], + }; + assert.equal(canPerformDocAction('create_project', customEngineer, DEFAULT_ROLES), true); +}); + +test('用例 9: filterVisibleDeliverablesWithRBAC 能够批处理过滤大盘交付物', () => { + const docs: DeliverableItem[] = [ + createMockDoc({ docId: 'doc-1', title: '常规工作流' }), + createMockDoc({ + docId: 'doc-2', + title: '绝密算法', + author: '外聘专家', + visibilityRule: { mode: 'role_whitelist', allowedRoleIds: ['role-director'] }, + }), + createMockDoc({ + docId: 'doc-3', + title: '张工个人草案', + category: 'private', + creatorId: 1, + }), + ]; + + // 张工视角: 能看常规工作流(doc-1)和个人草案(doc-3),不能看绝密算法(doc-2) + const zhangVisible = filterVisibleDeliverablesWithRBAC(docs, sampleUsers.engineer, DEFAULT_ROLES); + assert.deepEqual( + zhangVisible.map((d) => d.docId), + ['doc-1', 'doc-3'] + ); + + // 王总监视角: 能看常规工作流(doc-1)和绝密算法(doc-2),不能看张工私密草案(doc-3) + const wangVisible = filterVisibleDeliverablesWithRBAC(docs, sampleUsers.director, DEFAULT_ROLES); + assert.deepEqual( + wangVisible.map((d) => d.docId), + ['doc-1', 'doc-2'] + ); +}); diff --git a/web/src/engine/rbac.ts b/web/src/engine/rbac.ts new file mode 100644 index 00000000..311d1481 --- /dev/null +++ b/web/src/engine/rbac.ts @@ -0,0 +1,215 @@ +import { RoleDefinition, PermissionKey } from '../types/rbac'; +import { UserProfile } from '../types/docgov'; +import { DeliverableItem } from '../types/workbench'; + +/** + * 系统内置与预置全局角色库 + */ +export const DEFAULT_ROLES: RoleDefinition[] = [ + { + id: 'role-admin', + name: '系统管理员', + code: 'ADMIN', + description: '拥有系统所有功能与数据的全量管理权限(防死锁兜底)。', + isSystem: true, + permissions: [ + 'project:create', + 'project:assign_lead', + 'perm:manage', + 'workflow:config_visibility', + 'doc:override_visibility', + 'doc:export', + 'workflow:view_all', + ], + createdAt: '2026-09-08 00:00', + updatedAt: '2026-09-08 00:00', + }, + { + id: 'role-director', + name: '研发总监', + code: 'DIRECTOR', + description: '负责研发中心整体项目决策、立项审查、权限监督与终审批准。', + isSystem: false, + permissions: [ + 'project:create', + 'project:assign_lead', + 'workflow:config_visibility', + 'doc:override_visibility', + 'doc:export', + 'workflow:view_all', + ], + createdAt: '2026-09-08 00:00', + updatedAt: '2026-09-08 00:00', + }, + { + id: 'role-tech-lead', + name: '研发技术主管', + code: 'TECH_LEAD', + description: '负责具体研发管线的进度把控、文档责任人分配与技术审核。', + isSystem: false, + permissions: [ + 'project:create', + 'project:assign_lead', + 'doc:override_visibility', + 'doc:export', + 'workflow:view_all', + ], + createdAt: '2026-09-08 00:00', + updatedAt: '2026-09-08 00:00', + }, + { + id: 'role-engineer', + name: '方案工程师', + code: 'ENGINEER', + description: '负责医疗器械规格设计、受控正文编制、技术测算与推演。', + isSystem: false, + permissions: ['doc:export', 'workflow:view_all'], + createdAt: '2026-09-08 00:00', + updatedAt: '2026-09-08 00:00', + }, + { + id: 'role-guest', + name: '外部审计/访客', + code: 'GUEST', + description: '仅允许通过单文档覆盖或公开授权查阅特定报告,默认不可见内部工作流。', + isSystem: false, + permissions: ['doc:export'], + createdAt: '2026-09-08 00:00', + updatedAt: '2026-09-08 00:00', + }, +]; + +/** + * 判定用户是否具备指定全局功能权限 + */ +export function hasPermission( + user: UserProfile, + roles: RoleDefinition[], + permKey: PermissionKey +): boolean { + // 1. 查找用户角色定义 + const role = roles.find((r) => r.id === user.roleId); + + // 2. 超级管理员拥有全量特权通配 + if (role?.code === 'ADMIN' || role?.permissions.includes('perm:manage')) { + return true; + } + + // 3. 用户个人级特例微调优先 (追加权限) + if (user.customPermissions?.includes(permKey)) { + return true; + } + + // 4. 角色默认权限集合 + if (role && role.permissions.includes(permKey)) { + return true; + } + + return false; +} + +/** + * 交付物可见性 5 步决策流水线 + * 1. private 个人私密文档:严格隔离,仅创建者本人可见(绝对红线) + * 2. shared 项目协作资料:项目全员可见 + * 3. 管理员特权:ADMIN / perm:manage 全员可见 + * 4. 三权法定责任人保底:编制人、审核人、批准人必定可见 + * 5. 单文档覆盖规则 (Override Priority): + * - designated_only: 仅责任人可见 (非责任人已在步骤4排除,直接拒绝) + * - role_whitelist: 仅白名单内角色可见 + * - public: 全员可见 + * 6. 回退继承工作流基线:用户角色需具备 workflow:view_all 权限 + */ +export function isDeliverableVisible( + user: UserProfile, + roles: RoleDefinition[], + doc: DeliverableItem +): boolean { + // 步骤 1:个人私密文档 (private) 绝对隔离 + if (doc.category === 'private') { + return doc.creatorId === user.id; + } + + // 步骤 2:项目协作参考库 (shared) 对项目成员全员可见 + if (doc.category === 'shared') { + return true; + } + + // 步骤 3:管理员超级特权 + const role = roles.find((r) => r.id === user.roleId); + if (role?.code === 'ADMIN' || role?.permissions.includes('perm:manage')) { + return true; + } + + // 步骤 4:法定三权责任人保底 (防止指派后本人看不见无法履责) + const isAssignee = + doc.author === user.name || + doc.reviewer === user.name || + doc.approver === user.name; + if (isAssignee) { + return true; + } + + // 步骤 5:单文档覆盖策略判定 + const rule = doc.visibilityRule; + if (rule && rule.mode !== 'inherit') { + // 5.1 特例人员白名单 (特聘临床专家等) + if (rule.allowedUserIds?.includes(user.id)) { + return true; + } + + switch (rule.mode) { + case 'designated_only': + // 步骤 4 已经排除了非责任人,因此此处直接拒绝 + return false; + + case 'role_whitelist': + // 判定当前用户全局角色是否在白名单中 + return Boolean(user.roleId && rule.allowedRoleIds?.includes(user.roleId)); + + case 'public': + // 放宽为全员公开可见 + return true; + + default: + break; + } + } + + // 步骤 6:回退继承工作流默认基线规则 + return hasPermission(user, roles, 'workflow:view_all'); +} + +/** + * 批量过滤大盘交付物 + */ +export function filterVisibleDeliverablesWithRBAC( + deliverables: DeliverableItem[], + user: UserProfile, + roles: RoleDefinition[] +): DeliverableItem[] { + return deliverables.filter((doc) => isDeliverableVisible(user, roles, doc)); +} + +/** + * 核心业务动作权限拦截判定 + */ +export function canPerformDocAction( + action: 'assign_lead' | 'override_visibility' | 'create_project', + user: UserProfile, + roles: RoleDefinition[] +): boolean { + switch (action) { + case 'create_project': + return hasPermission(user, roles, 'project:create'); + case 'assign_lead': + return hasPermission(user, roles, 'project:assign_lead'); + case 'override_visibility': + return ( + hasPermission(user, roles, 'doc:override_visibility') || + hasPermission(user, roles, 'perm:manage') + ); + default: + return false; + } +}