权限管理:接入项目级权限矩阵

新增项目级角色权限矩阵,覆盖 PM、CRA、PV、IMP、QA 等项目角色,并通过迁移表持久化各业务模块的读写权限。

后端将参与者、合同费用、物资、启动、里程碑、风险问题、监查稽查、FAQ、共享库、文件版本等接口接入矩阵校验,修复审计日志导出权限和登录 502 的依赖导入问题。

前端新增权限管理页面和项目路由权限映射,按矩阵控制导航显示、直接访问拦截、操作按钮启停,并限制管理后台仅 admin 和授权 PM 可进入。

补充后端权限归一化与接口静态测试、前端路由/权限工具/权限管理页面/工作台等回归测试。
This commit is contained in:
Cheng Zhou
2026-05-13 08:58:03 +08:00
parent 77e842637d
commit 939fc70532
63 changed files with 1952 additions and 387 deletions
+5 -11
View File
@@ -5,21 +5,15 @@ import { resolve } from "node:path";
const readPermissionSource = () => readFileSync(resolve(__dirname, "./permission.ts"), "utf8");
describe("permission project role model", () => {
it("does not fall back to non-admin global roles for project permissions", () => {
it("does not fall back to project role names for permission decisions", () => {
const source = readPermissionSource();
expect(source).toContain("isSystemAdmin");
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).not.toContain("role_in_study || auth.user?.role");
expect(source).not.toContain("role_in_study || userRole.value");
});
it("allows project administrators and PMs to manage project configuration", () => {
const source = readPermissionSource();
expect(source).toContain('"project.members.manage": ["ADMIN", "PM"]');
expect(source).toContain('"site.manage": ["ADMIN", "PM"]');
expect(source).toContain('"site.cra.bind": ["ADMIN", "PM"]');
expect(source).toContain("study.currentPermissions");
expect(source).toContain('"faq.edit": { module: "faq", action: "write" }');
expect(source).not.toContain('"faq.edit": { module: "etmf"');
expect(source).not.toContain('"ADMIN", "PM"');
});
});
+40 -16
View File
@@ -5,19 +5,19 @@ import { TEXT } from "../locales";
import { getProjectRole, isSystemAdmin } from "./roles";
const PERMISSIONS: Record<string, string[]> = {
"subject.create": ["ADMIN", "PM", "CRA"],
"subject.enroll": ["ADMIN", "PM", "CRA"],
"subject.complete": ["ADMIN", "PM", "CRA"],
"subject.drop": ["ADMIN", "PM", "CRA"],
"ae.create": ["ADMIN", "PM", "CRA", "PV"],
"ae.close": ["ADMIN", "PM", "PV", "CRA"],
"faq.edit": ["ADMIN", "PM"],
"faq.create": ["ADMIN", "PM", "CRA", "PV", "IMP"],
"faq.reply": ["ADMIN", "PM", "CRA", "PV", "IMP"],
"project.members.manage": ["ADMIN", "PM"],
"site.manage": ["ADMIN", "PM"],
"site.cra.bind": ["ADMIN", "PM"],
"fees.contract.write": ["ADMIN", "PM"],
"subject.create": ["ADMIN"],
"subject.enroll": ["ADMIN"],
"subject.complete": ["ADMIN"],
"subject.drop": ["ADMIN"],
"ae.create": ["ADMIN"],
"ae.close": ["ADMIN"],
"faq.edit": ["ADMIN"],
"faq.create": ["ADMIN"],
"faq.reply": ["ADMIN"],
"project.members.manage": ["ADMIN"],
"site.manage": ["ADMIN"],
"site.cra.bind": ["ADMIN"],
"fees.contract.write": ["ADMIN"],
};
const REASONS: Record<string, string> = {
@@ -40,13 +40,37 @@ export const usePermission = () => {
const systemAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const projectPermissions = computed(() => study.currentPermissions);
const can = (action: string): boolean => {
if (systemAdmin.value) return true;
const allowList = PERMISSIONS[action];
if (!allowList) return false;
if (!projectRole.value) return false;
return allowList.includes(projectRole.value);
const permissionMap: Record<string, { module: string; action: "read" | "write" }> = {
"subject.create": { module: "subjects", action: "write" },
"subject.enroll": { module: "subjects", action: "write" },
"subject.complete": { module: "subjects", action: "write" },
"subject.drop": { module: "subjects", action: "write" },
"ae.create": { module: "risk_issues", action: "write" },
"ae.close": { module: "risk_issues", action: "write" },
"project.overview.read": { module: "project_overview", action: "read" },
"faq.edit": { module: "faq", action: "write" },
"faq.create": { module: "faq", action: "write" },
"faq.reply": { module: "faq", action: "write" },
"knowledge.notes.write": { module: "shared_library", action: "write" },
"shared.library.read": { module: "shared_library", action: "read" },
"shared.library.write": { module: "shared_library", action: "write" },
"project.members.manage": { module: "project_members", action: "write" },
"site.manage": { module: "sites", action: "write" },
"site.cra.bind": { module: "sites", action: "write" },
"fees.contract.write": { module: "fees", action: "write" },
"fees.attachment.delete": { module: "fees", action: "write" },
"file.attachment.delete": { module: "file_versions", action: "write" },
"audit.export.read": { module: "audit_export", action: "read" },
};
const mapped = permissionMap[action];
if (!mapped) return false;
const allowed = projectPermissions.value?.roles?.[projectRole.value]?.[mapped.module]?.[mapped.action];
return !!allowed;
};
const reason = (action: string): string => {
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "./projectRoutePermissions";
describe("project route permissions", () => {
it("maps project routes to read permission modules", () => {
expect(getProjectRoutePermission("/project/overview")).toEqual({ module: "project_overview", action: "read" });
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ module: "faq", action: "read" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ module: "shared_library", action: "read" });
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ module: "startup_auth", action: "read" });
});
it("rejects project routes when the role lacks module read permission", () => {
const matrix = {
CRA: {
project_overview: { read: false, write: false },
shared_library: { read: true, write: true },
},
} as any;
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/project/overview"), false)).toBe(false);
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/notes"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/notes");
});
});
@@ -0,0 +1,69 @@
import type { ProjectPermissionMatrix, UserRole } from "../types/api";
export type ProjectRoutePermission = {
module: string;
action: "read" | "write";
};
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
{ prefixes: ["/project/overview"], permission: { module: "project_overview", action: "read" } },
{ prefixes: ["/project/milestones"], permission: { module: "project_milestones", action: "read" } },
{ prefixes: ["/fees/contracts", "/finance/contracts"], permission: { module: "fees", action: "read" } },
{ prefixes: ["/drug/shipments", "/materials/equipment"], permission: { module: "materials", action: "read" } },
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { module: "file_versions", action: "read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { module: "startup_ethics", action: "read" } },
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { module: "startup_auth", action: "read" } },
{ prefixes: ["/subjects"], permission: { module: "subjects", action: "read" } },
{ prefixes: ["/risk-issues"], permission: { module: "risk_issues", action: "read" } },
{ prefixes: ["/monitoring-audit", "/monitoring", "/audit"], permission: { module: "monitoring_audit", action: "read" } },
{ prefixes: ["/etmf"], permission: { module: "etmf", action: "read" } },
{ prefixes: ["/knowledge/medical-consult"], permission: { module: "faq", action: "read" } },
{ prefixes: ["/knowledge/notes", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { module: "shared_library", action: "read" } },
];
export const projectRouteLandingPaths = [
"/project/overview",
"/project/milestones",
"/fees/contracts",
"/drug/shipments",
"/materials/equipment",
"/file-versions",
"/startup/feasibility-ethics",
"/startup/meeting-auth",
"/subjects",
"/risk-issues/sae",
"/monitoring-audit",
"/etmf",
"/knowledge/medical-consult",
"/knowledge/notes",
"/knowledge/support-files",
"/knowledge/instruction-files",
];
export const getProjectRoutePermission = (path: string): ProjectRoutePermission | null => {
const normalized = path || "";
for (const item of routePermissions) {
if (item.prefixes.some((prefix) => normalized === prefix || normalized.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`))) {
return item.permission;
}
}
return null;
};
export const hasProjectPermission = (
matrix: ProjectPermissionMatrix | null | undefined,
role: string | null | undefined,
permission: ProjectRoutePermission | null,
isAdmin: boolean,
) => {
if (!permission || isAdmin) return true;
if (!role) return false;
const typedRole = role as UserRole;
return !!matrix?.[typedRole]?.[permission.module]?.[permission.action];
};
export const findFirstAccessibleProjectPath = (
matrix: ProjectPermissionMatrix | null | undefined,
role: string | null | undefined,
isAdmin: boolean,
) => projectRouteLandingPaths.find((path) => hasProjectPermission(matrix, role, getProjectRoutePermission(path), isAdmin)) || null;
+1 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getProjectRole, getSystemRole, isProjectAdminOrPm, isSystemAdmin } from "./roles";
import { getProjectRole, getSystemRole, isSystemAdmin } from "./roles";
describe("role helpers", () => {
it("keeps system admin separate from project admin", () => {
@@ -11,8 +11,5 @@ describe("role helpers", () => {
it("uses only project role fields for project permissions", () => {
expect(getProjectRole({ role_in_study: "PM" } as any, null)).toBe("PM");
expect(getProjectRole({ role_in_study: null } as any, "ADMIN")).toBe("ADMIN");
expect(isProjectAdminOrPm("ADMIN")).toBe(true);
expect(isProjectAdminOrPm("PM")).toBe(true);
expect(isProjectAdminOrPm("CRA")).toBe(false);
});
});
-2
View File
@@ -6,5 +6,3 @@ export const isSystemAdmin = (user?: Pick<UserInfo, "role"> | null) => getSystem
export const getProjectRole = (study?: Pick<Study, "role_in_study"> | null, currentStudyRole?: string | null) =>
currentStudyRole || study?.role_in_study || null;
export const isProjectAdminOrPm = (role?: string | null) => role === "ADMIN" || role === "PM";