feat(v2.7): 收口治理权限适配器
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user