fix(权限): 修复版本详情写入权限校验

This commit is contained in:
2026-07-08 19:27:41 +08:00
parent cafa0f134a
commit deebc5404a
5 changed files with 191 additions and 17 deletions

View File

@@ -5,16 +5,19 @@ import type { CurrentUser } from './auth-context.service';
describe('PermissionService', () => {
const versionFindUnique = jest.fn();
const projectMemberFindUnique = jest.fn();
const appDataFindUnique = jest.fn();
const prisma = {
version: { findUnique: versionFindUnique },
projectMember: { findUnique: projectMemberFindUnique },
appData: { findUnique: appDataFindUnique },
} as any;
const service = new PermissionService(prisma);
beforeEach(() => {
jest.clearAllMocks();
appDataFindUnique.mockResolvedValue(null);
});
it('allows the built-in super admin wildcard for any permission without a scope lookup', async () => {
@@ -26,13 +29,13 @@ describe('PermissionService', () => {
expect(projectMemberFindUnique).not.toHaveBeenCalled();
});
it('requires scoped users to belong to the project resolved from a version', async () => {
it('denies version work mutations for project members outside the version members list', async () => {
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', {
versionId: 'version-1',
})).resolves.toBe(true);
})).resolves.toBe(false);
expect(versionFindUnique).toHaveBeenCalledWith({
where: { id: 'version-1' },
@@ -43,6 +46,39 @@ describe('PermissionService', () => {
});
});
it('allows version work mutations when the user has the role permission and is a version member', async () => {
versionFindUnique.mockResolvedValue({
id: 'version-1',
projectId: 'project-1',
members: [{ id: 'dev-1', name: 'Dev One' }],
});
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
await expect(service.can(currentUser({ id: 'dev-1', name: 'Dev One', roleId: 'role-dev' }), 'version.devtask:manage', {
versionId: 'version-1',
})).resolves.toBe(true);
});
it('uses configured role permissions before built-in role defaults', async () => {
appDataFindUnique.mockResolvedValue({
value: {
roles: [
{ id: 'role-dev', permissions: ['version.devtask:view'] },
],
},
});
versionFindUnique.mockResolvedValue({
id: 'version-1',
projectId: 'project-1',
members: [{ id: 'dev-1', name: 'Dev One' }],
});
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
await expect(service.can(currentUser({ id: 'dev-1', name: 'Dev One', roleId: 'role-dev' }), 'version.devtask:manage', {
versionId: 'version-1',
})).resolves.toBe(false);
});
it('allows a version member when no project membership exists', async () => {
versionFindUnique.mockResolvedValue({
id: 'version-1',

View File

@@ -101,13 +101,26 @@ const PROJECT_ROLE_PERMISSIONS: Record<string, string[]> = {
],
};
const VERSION_WORK_MUTATION_PERMISSIONS = new Set([
'version.req:manage',
'version.research:manage',
'version.product_plan:manage',
'version.ui_plan:manage',
'version.devtask:manage',
'version.testcase:manage',
'version.bug:create',
'version.bug:edit',
'version.bug:delete',
]);
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaService) {}
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
if (!user) return false;
const systemPermissions = SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? [];
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;
const systemPermissions = await this.resolveRolePermissions(user.roleId);
if (systemPermissions.includes('*')) return true;
const hasSystemPermission = systemPermissions.includes(permission);
@@ -119,6 +132,10 @@ export class PermissionService {
where: { projectId_userId: { projectId: resolvedScope.projectId, userId: user.id } },
}) : null;
if (requiresVersionWorkMembership(permission, resolvedScope)) {
return hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers);
}
if (projectMember && roleAllows(PROJECT_ROLE_PERMISSIONS[projectMember.role] ?? [], permission)) {
return true;
}
@@ -146,6 +163,26 @@ export class PermissionService {
versionMembers: version?.members,
};
}
private async resolveRolePermissions(roleId: string): Promise<string[]> {
const configuredPermissions = await this.findConfiguredRolePermissions(roleId);
return configuredPermissions ?? SYSTEM_ROLE_PERMISSIONS[roleId] ?? [];
}
private async findConfiguredRolePermissions(roleId: string): Promise<string[] | null> {
const findUnique = (this.prisma as unknown as {
appData?: { findUnique?: (args: unknown) => Promise<{ value?: unknown } | null> };
}).appData?.findUnique;
if (!findUnique) return null;
const row = await findUnique({
where: { key: 'members' },
select: { value: true },
}).catch(() => null);
if (!row) return null;
return readRolePermissions(row.value, roleId);
}
}
function std4(module: string): string[] {
@@ -156,6 +193,25 @@ function roleAllows(permissions: string[], permission: string): boolean {
return permissions.includes('*') || permissions.includes(permission);
}
function requiresVersionWorkMembership(permission: string, scope: ResourceScope): boolean {
return Boolean(scope.versionId && VERSION_WORK_MUTATION_PERMISSIONS.has(permission));
}
function readRolePermissions(value: unknown, roleId: string): string[] | null {
if (!value || typeof value !== 'object') return null;
const roles = (value as { roles?: unknown }).roles;
if (!Array.isArray(roles)) return null;
const role = roles.find((item) => (
item &&
typeof item === 'object' &&
(item as { id?: unknown }).id === roleId
));
if (!role || typeof role !== 'object') return null;
const permissions = (role as { permissions?: unknown }).permissions;
if (!Array.isArray(permissions)) return null;
return permissions.filter((item): item is string => typeof item === 'string');
}
function isVersionMember(user: CurrentUser, members: unknown): boolean {
if (!Array.isArray(members)) return false;
return members.some((member) => {