Files
lingxi/packages/shared/src/permissions.ts
T
Stev_Wang 61894df401 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 全绿。
2026-11-03 12:26:51 +08:00

467 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 权限点目录(A0-SH-01 骨架 → A2 定稿,口径见 H1 权限门)
*
* 命名规范:<模块>:<动作>,全小写;权限树一级模块(script / material / asset / delivery /
* customer / customer_group / identity / industry / platform / audit)与 UI 原型一致,
* 动作使用动词或名词短语。
*
* 约束(AGENTS §10 / 开发计划 §14 附录 B):
* - 权限点总共 30 个:保留 A0/A1 已有的 read / write 编码,只补齐 H1 确认的 5 个新编码;
* - 新增 / 删除 / 改名权限点必须评审,`code` 一经发布不得变更(角色数据按 code 关联);
* - 特殊权限 6 个编码(5 类):默认关闭、单独授予、不随角色复制带入;
* - 本文件是唯一事实源:后端启动时同步 `permissions` 表,前端权限树由 `/permissions` 生成;
* 本文件不依赖任何其他模块,保证 `@lingxi/shared` 零业务依赖。
*/
// ---------------------------------------------------------------------------
// 模块(权限树一级分组,共 10 个;与 design/a2-ui-v1/brief.md 对齐,顺序固定)
// ---------------------------------------------------------------------------
export const PERMISSION_MODULES = [
{ module: 'script', moduleLabel: '脚本', sortOrder: 10 },
{ module: 'material', moduleLabel: '素材', sortOrder: 20 },
{ module: 'asset', moduleLabel: '资产', sortOrder: 30 },
{ module: 'delivery', moduleLabel: '导出交付', sortOrder: 40 },
{ module: 'customer', moduleLabel: '客户', sortOrder: 50 },
{ module: 'customer_group', moduleLabel: '客户组', sortOrder: 60 },
{ module: 'identity', moduleLabel: '组织与角色', sortOrder: 70 },
{ module: 'industry', moduleLabel: '行业包', sortOrder: 80 },
{ module: 'platform', moduleLabel: '平台配置', sortOrder: 90 },
{ module: 'audit', moduleLabel: '审计', sortOrder: 100 },
] as const;
export type PermissionModule = (typeof PERMISSION_MODULES)[number]['module'];
// ---------------------------------------------------------------------------
// 权限点编码(30 个)
// ---------------------------------------------------------------------------
export const Permission = {
// 脚本
SCRIPT_READ: 'script:read',
SCRIPT_WRITE: 'script:write',
SCRIPT_REVIEW: 'script:review',
// 素材
MATERIAL_READ: 'material:read',
MATERIAL_WRITE: 'material:write',
MATERIAL_RENDER: 'material:render',
MATERIAL_REVIEW: 'material:review',
// 资产
ASSET_READ: 'asset:read',
ASSET_WRITE: 'asset:write',
ASSET_DELETE: 'asset:delete',
// 交付导出
DELIVERY_READ: 'delivery:read',
DELIVERY_EXPORT: 'delivery:export',
// 客户
CUSTOMER_READ: 'customer:read',
CUSTOMER_MANAGE: 'customer:manage',
CUSTOMER_TRANSFER: 'customer:transfer',
CUSTOMER_FIELD_APPLY: 'customer_field:apply',
CUSTOMER_FIELD_APPROVE: 'customer_field:approve',
// 客户组
CUSTOMER_GROUP_MANAGE: 'customer_group:manage',
// 组织与角色
USER_VIEW: 'user:view',
USER_MANAGE: 'user:manage',
ROLE_VIEW: 'role:view',
ROLE_MANAGE: 'role:manage',
// 行业包
INDUSTRY_MANAGE: 'industry:manage',
INDUSTRY_PUBLISH: 'industry:publish',
// 平台配置
DICT_MANAGE: 'dict:manage',
FORM_DESIGN: 'form:design',
AI_CONFIGURE: 'ai:configure',
SYSTEM_CONFIGURE: 'system:configure',
// 审计
AUDIT_READ: 'audit:read',
AUDIT_EXPORT: 'audit:export',
} as const;
export type PermissionCode = (typeof Permission)[keyof typeof Permission];
// ---------------------------------------------------------------------------
// 特殊权限(6 编码 / 5 类,默认关闭、单独授予、不随角色复制带入)
// ---------------------------------------------------------------------------
/** 需要 SpecialPermissionGuard 单独校验的特殊权限(架构 §6.2 / PRD §5.4.3 五) */
export const SPECIAL_PERMISSIONS: readonly PermissionCode[] = [
Permission.DELIVERY_EXPORT,
Permission.CUSTOMER_FIELD_APPROVE,
Permission.INDUSTRY_PUBLISH,
Permission.AI_CONFIGURE,
Permission.AUDIT_READ,
Permission.AUDIT_EXPORT,
];
const SPECIAL_PERMISSION_SET: ReadonlySet<string> = new Set<string>(SPECIAL_PERMISSIONS);
export function isSpecialPermission(code: string): boolean {
return SPECIAL_PERMISSION_SET.has(code);
}
// ---------------------------------------------------------------------------
// 职责互斥三对(SoD;超管豁免,命中返回 40304)
// ---------------------------------------------------------------------------
export interface SodPair {
id: string;
label: string;
description: string;
permissions: readonly [PermissionCode, PermissionCode];
}
export interface SodConflict {
pairId: string;
label: string;
permissions: readonly [PermissionCode, PermissionCode];
}
export const SOD_PAIRS: readonly SodPair[] = [
{
id: 'script-write-review',
label: '脚本生成 × 脚本审核',
description: '自己写自己审会让审核形同虚设,同一角色不得同时拥有「脚本生成」与「脚本审核」。',
permissions: [Permission.SCRIPT_WRITE, Permission.SCRIPT_REVIEW],
},
{
id: 'industry-manage-publish',
label: '行业包编辑 × 行业包发布',
description: '行业包改动影响所有客户,编辑与发布必须由两个角色分别承担。',
permissions: [Permission.INDUSTRY_MANAGE, Permission.INDUSTRY_PUBLISH],
},
{
id: 'customer-field-apply-approve',
label: '客户字段申请 × 客户字段审批',
description: '字段申请与审批不得由同一角色完成,避免绕过审批直接进入生成。',
permissions: [Permission.CUSTOMER_FIELD_APPLY, Permission.CUSTOMER_FIELD_APPROVE],
},
];
/** 命中任意一对 SoD 即返回该对明细(role 保存前校验,超管豁免) */
export function findSodConflicts(codes: readonly string[]): SodConflict[] {
const codeSet = new Set(codes);
return SOD_PAIRS.filter(
(pair) => codeSet.has(pair.permissions[0]) && codeSet.has(pair.permissions[1]),
).map((pair) => ({
pairId: pair.id,
label: pair.label,
permissions: pair.permissions,
}));
}
// ---------------------------------------------------------------------------
// 软上限(建议值,仅提示不阻断;A4 迁 system_configs)
// ---------------------------------------------------------------------------
/** 角色数量建议上限(PRD §5.4.3 数量建议:角色 ≤ 20) */
export const ROLE_SOFT_LIMIT = 20;
/** 超级管理员人数建议上限(PRD §5.4.1:建议 ≤ 2 人) */
export const SUPER_ADMIN_SOFT_LIMIT = 2;
// ---------------------------------------------------------------------------
// 权限点目录元数据
// ---------------------------------------------------------------------------
export interface PermissionMeta {
code: PermissionCode;
module: PermissionModule;
moduleLabel: string;
name: string;
description: string;
isSpecial: boolean;
sortOrder: number;
}
interface PermissionDefinition {
code: PermissionCode;
module: PermissionModule;
name: string;
description: string;
}
/** 目录定义(按模块 + 操作顺序),`PERMISSION_CATALOG` 由此生成 */
const PERMISSION_DEFINITIONS: readonly PermissionDefinition[] = [
// 脚本
{
code: Permission.SCRIPT_READ,
module: 'script',
name: '查看脚本',
description: '脚本库、模板与版本',
},
{
code: Permission.SCRIPT_WRITE,
module: 'script',
name: '编辑与生成脚本',
description: '编辑脚本、发起生成',
},
{
code: Permission.SCRIPT_REVIEW,
module: 'script',
name: '审核脚本',
description: '脚本入库审核',
},
// 素材
{
code: Permission.MATERIAL_READ,
module: 'material',
name: '查看素材任务',
description: '素材与任务详情',
},
{
code: Permission.MATERIAL_WRITE,
module: 'material',
name: '编辑素材任务',
description: '参数与素材信息',
},
{
code: Permission.MATERIAL_RENDER,
module: 'material',
name: '发起生成与渲染',
description: '图片 / 视频生成',
},
{
code: Permission.MATERIAL_REVIEW,
module: 'material',
name: '审核素材',
description: '素材入库审核',
},
// 资产
{ code: Permission.ASSET_READ, module: 'asset', name: '查看资产', description: '资产库与版本' },
{
code: Permission.ASSET_WRITE,
module: 'asset',
name: '上传与编辑资产',
description: '上传、编辑、启停',
},
{
code: Permission.ASSET_DELETE,
module: 'asset',
name: '删除资产',
description: '受引用保护,仅未被引用可删',
},
// 导出交付
{
code: Permission.DELIVERY_READ,
module: 'delivery',
name: '查看交付记录',
description: '交付清单与导出记录',
},
{
code: Permission.DELIVERY_EXPORT,
module: 'delivery',
name: '发起导出',
description: '数据离开平台的唯一出口(特殊)',
},
// 客户
{
code: Permission.CUSTOMER_READ,
module: 'customer',
name: '查看客户',
description: '客户资料与归属',
},
{
code: Permission.CUSTOMER_MANAGE,
module: 'customer',
name: '管理客户资料',
description: '新建、编辑、停用',
},
{
code: Permission.CUSTOMER_TRANSFER,
module: 'customer',
name: '转交客户',
description: '变更客户负责人',
},
{
code: Permission.CUSTOMER_FIELD_APPLY,
module: 'customer',
name: '申请客户字段',
description: '提交扩展字段申请',
},
{
code: Permission.CUSTOMER_FIELD_APPROVE,
module: 'customer',
name: '审批客户字段',
description: '审批扩展字段(特殊)',
},
// 客户组
{
code: Permission.CUSTOMER_GROUP_MANAGE,
module: 'customer_group',
name: '管理客户组',
description: '客户组增改与成员',
},
// 组织与角色
{ code: Permission.USER_VIEW, module: 'identity', name: '查看用户', description: '用户与部门' },
{
code: Permission.USER_MANAGE,
module: 'identity',
name: '管理用户',
description: '新建、停用、转交',
},
{
code: Permission.ROLE_VIEW,
module: 'identity',
name: '查看角色',
description: '角色、权限点与 SoD',
},
{
code: Permission.ROLE_MANAGE,
module: 'identity',
name: '管理角色与授权',
description: '新建、编辑、复制、停用',
},
// 行业包
{
code: Permission.INDUSTRY_MANAGE,
module: 'industry',
name: '编辑行业包',
description: '草稿、schema、规则集',
},
{
code: Permission.INDUSTRY_PUBLISH,
module: 'industry',
name: '发布行业包',
description: '发布后影响全部客户(特殊)',
},
// 平台配置
{
code: Permission.DICT_MANAGE,
module: 'platform',
name: '管理字典',
description: '字典与系统配置项',
},
{ code: Permission.FORM_DESIGN, module: 'platform', name: '设计表单', description: '表单设计器' },
{
code: Permission.AI_CONFIGURE,
module: 'platform',
name: 'AI 配置',
description: '模型、路由、额度(特殊)',
},
{
code: Permission.SYSTEM_CONFIGURE,
module: 'platform',
name: '系统配置',
description: '平台参数',
},
// 审计
{
code: Permission.AUDIT_READ,
module: 'audit',
name: '查看审计',
description: '审计日志检索(特殊)',
},
{
code: Permission.AUDIT_EXPORT,
module: 'audit',
name: '导出审计',
description: '审计导出并留痕(特殊)',
},
];
export const PERMISSION_CATALOG: readonly PermissionMeta[] = PERMISSION_DEFINITIONS.map((item) => {
const moduleMeta = PERMISSION_MODULES.find((module) => module.module === item.module);
if (moduleMeta === undefined) {
throw new Error(`权限点 ${item.code} 引用了未登记的模块:${item.module}`);
}
const indexInModule = PERMISSION_DEFINITIONS.filter(
(candidate) => candidate.module === item.module,
).findIndex((candidate) => candidate.code === item.code);
return {
code: item.code,
module: item.module,
moduleLabel: moduleMeta.moduleLabel,
name: item.name,
description: item.description,
isSpecial: isSpecialPermission(item.code),
sortOrder: moduleMeta.sortOrder + indexInModule,
};
});
export const PERMISSION_CODES: readonly PermissionCode[] = PERMISSION_CATALOG.map(
(item) => item.code,
);
const PERMISSION_CODE_SET: ReadonlySet<string> = new Set<string>(PERMISSION_CODES);
export function isPermissionCode(value: string): value is PermissionCode {
return PERMISSION_CODE_SET.has(value);
}
const PERMISSION_META_BY_CODE: ReadonlyMap<string, PermissionMeta> = new Map(
PERMISSION_CATALOG.map((item) => [item.code, item]),
);
export function permissionMetaOf(code: string): PermissionMeta | undefined {
return PERMISSION_META_BY_CODE.get(code);
}
/** 按模块分组(保持 PERMISSION_MODULES 顺序,空模块不返回) */
export function permissionsByModule(): readonly {
module: PermissionModule;
moduleLabel: string;
sortOrder: number;
permissions: readonly PermissionMeta[];
}[] {
return PERMISSION_MODULES.map((moduleMeta) => ({
module: moduleMeta.module,
moduleLabel: moduleMeta.moduleLabel,
sortOrder: moduleMeta.sortOrder,
permissions: PERMISSION_CATALOG.filter((item) => item.module === moduleMeta.module),
})).filter((group) => group.permissions.length > 0);
}
// ---------------------------------------------------------------------------
// 解析与校验
// ---------------------------------------------------------------------------
export interface ParsedPermission {
module: string;
action: string;
}
/** 拆分权限点为 { module, action };格式不合法返回 null */
export function parsePermission(code: string): ParsedPermission | null {
const separator = code.indexOf(':');
if (separator <= 0 || separator === code.length - 1) {
return null;
}
const module = code.slice(0, separator);
const action = code.slice(separator + 1);
if (module.includes(':') || action.includes(':')) {
return null;
}
return { module, action };
}
/**
* 校验并拆分角色保存入参的权限点:返回普通权限点与特殊权限点。
* 未登记编码抛 `INVALID_PERMISSION_CODE:<code>`(由调用方映射为 40001)。
*/
export function splitPermissions(codes: readonly string[]): {
permissions: PermissionCode[];
specialPermissions: PermissionCode[];
} {
const unique = [...new Set(codes)];
for (const code of unique) {
if (!isPermissionCode(code)) {
throw new Error(`INVALID_PERMISSION_CODE:${code}`);
}
}
const permissions: PermissionCode[] = [];
const specialPermissions: PermissionCode[] = [];
for (const code of unique) {
if (isSpecialPermission(code)) {
specialPermissions.push(code as PermissionCode);
} else {
permissions.push(code as PermissionCode);
}
}
return {
permissions: permissions.sort(),
specialPermissions: specialPermissions.sort(),
};
}