feat(v2.7): 收口治理权限适配器

This commit is contained in:
2026-07-08 16:44:57 +08:00
parent 9ba9449c1a
commit f69ec83193
14 changed files with 214 additions and 16 deletions

View File

@@ -29,6 +29,29 @@ describe('RbacService V2.7 adapter contract', () => {
expect(prisma.projectMember.findUnique).not.toHaveBeenCalled();
});
it('allows actors with an explicit global permission', async () => {
const { service } = makeService();
await expect(service.assertGlobalPermission({
actorId: 'm-pm',
permissions: ['management:view'],
requiredPermissions: ['management:view'],
})).resolves.toEqual({
actorId: 'm-pm',
via: 'permission',
});
});
it('rejects actors without the required global permission', async () => {
const { service } = makeService();
await expect(service.assertGlobalPermission({
actorId: 'm-dev',
permissions: ['project:view'],
requiredPermissions: ['governance:manage'],
})).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows project owners to perform admin-scoped actions', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({

View File

@@ -10,6 +10,12 @@ export interface ProjectRoleAssertion {
permissions?: string[];
}
export interface GlobalPermissionAssertion {
actorId?: string;
permissions?: string[];
requiredPermissions: string[];
}
export interface ProjectRoleDecision {
actorId: string;
projectId: string;
@@ -17,6 +23,11 @@ export interface ProjectRoleDecision {
via: 'system' | 'project_member';
}
export interface GlobalPermissionDecision {
actorId: string;
via: 'system' | 'permission';
}
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
owner: 4,
admin: 3,
@@ -28,6 +39,20 @@ const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
export class RbacService {
constructor(private readonly prisma: PrismaService) {}
async assertGlobalPermission(input: GlobalPermissionAssertion): Promise<GlobalPermissionDecision> {
const actorId = input.actorId?.trim();
if (!actorId) {
throw new ForbiddenException('Missing actor scope');
}
if (input.permissions?.includes('*')) {
return { actorId, via: 'system' };
}
if (hasAnyPermission(input.permissions ?? [], input.requiredPermissions)) {
return { actorId, via: 'permission' };
}
throw new ForbiddenException('Insufficient global permission');
}
async assertProjectRole(input: ProjectRoleAssertion): Promise<ProjectRoleDecision> {
const actorId = input.actorId?.trim();
const projectId = input.projectId?.trim();
@@ -61,3 +86,9 @@ export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: Proje
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
return ROLE_RANK[role] >= minimumRank;
}
export function hasAnyPermission(permissions: string[], requiredPermissions: string[]): boolean {
if (requiredPermissions.length === 0) return false;
const granted = new Set(permissions);
return requiredPermissions.some((permission) => granted.has(permission));
}