背景: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 全绿。
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
/**
|
||
* 权限点目录(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(),
|
||
};
|
||
}
|