feat(auth): 增加服务端权限上下文
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
||||||
|
import { AuthModule } from './common/auth/auth.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { ProductModule } from './modules/product/product.module';
|
import { ProductModule } from './modules/product/product.module';
|
||||||
import { ProjectModule } from './modules/project/project.module';
|
import { ProjectModule } from './modules/project/project.module';
|
||||||
@@ -24,6 +25,7 @@ import { HealthModule } from './modules/health/health.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
ProductModule,
|
ProductModule,
|
||||||
ProjectModule,
|
ProjectModule,
|
||||||
VersionModule,
|
VersionModule,
|
||||||
|
|||||||
62
apps/server/src/common/auth/auth-context.service.ts
Normal file
62
apps/server/src/common/auth/auth-context.service.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
username?: string;
|
||||||
|
roleId: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest {
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
currentUser?: CurrentUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthContextService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async resolveCurrentUser(request: AuthenticatedRequest): Promise<CurrentUser | null> {
|
||||||
|
if (request.currentUser !== undefined) return request.currentUser;
|
||||||
|
|
||||||
|
const userId = headerValue(request, 'x-ftb-user-id') || headerValue(request, 'x-user-id');
|
||||||
|
if (!userId) {
|
||||||
|
request.currentUser = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerUser: CurrentUser = {
|
||||||
|
id: userId,
|
||||||
|
name: headerValue(request, 'x-ftb-user-name'),
|
||||||
|
username: headerValue(request, 'x-ftb-user-username'),
|
||||||
|
roleId: headerValue(request, 'x-ftb-user-role-id') || '',
|
||||||
|
email: headerValue(request, 'x-ftb-user-email'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (headerUser.roleId) {
|
||||||
|
request.currentUser = headerUser;
|
||||||
|
return headerUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, name: true, username: true, roleId: true, email: true },
|
||||||
|
});
|
||||||
|
request.currentUser = row ? {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
username: row.username ?? undefined,
|
||||||
|
roleId: row.roleId || 'member',
|
||||||
|
email: row.email,
|
||||||
|
} : null;
|
||||||
|
return request.currentUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerValue(request: AuthenticatedRequest, name: string): string {
|
||||||
|
const value = request.headers?.[name] ?? request.headers?.[name.toLowerCase()];
|
||||||
|
if (Array.isArray(value)) return value[0] ?? '';
|
||||||
|
return value ?? '';
|
||||||
|
}
|
||||||
11
apps/server/src/common/auth/auth.module.ts
Normal file
11
apps/server/src/common/auth/auth.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuthContextService } from './auth-context.service';
|
||||||
|
import { PermissionGuard } from './permission.guard';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AuthContextService, PermissionGuard, PermissionService],
|
||||||
|
exports: [AuthContextService, PermissionGuard, PermissionService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
9
apps/server/src/common/auth/current-user.decorator.ts
Normal file
9
apps/server/src/common/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest, CurrentUser } from './auth-context.service';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): CurrentUser | null => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
return request.currentUser ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
17
apps/server/src/common/auth/permission.decorator.ts
Normal file
17
apps/server/src/common/auth/permission.decorator.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const PERMISSION_METADATA_KEY = 'ftb:required-permission';
|
||||||
|
|
||||||
|
export interface PermissionScopeOptions {
|
||||||
|
productIdParam?: string;
|
||||||
|
projectIdParam?: string;
|
||||||
|
versionIdParam?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequiredPermissionMetadata extends PermissionScopeOptions {
|
||||||
|
permission: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequirePermission(permission: string, scope: PermissionScopeOptions = {}) {
|
||||||
|
return SetMetadata(PERMISSION_METADATA_KEY, { permission, ...scope });
|
||||||
|
}
|
||||||
74
apps/server/src/common/auth/permission.guard.spec.ts
Normal file
74
apps/server/src/common/auth/permission.guard.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthContextService } from './auth-context.service';
|
||||||
|
import { PermissionGuard } from './permission.guard';
|
||||||
|
import { RequirePermission } from './permission.decorator';
|
||||||
|
|
||||||
|
describe('PermissionGuard', () => {
|
||||||
|
it('rejects protected routes when no current user can be resolved', async () => {
|
||||||
|
const guard = new PermissionGuard(
|
||||||
|
new Reflector(),
|
||||||
|
{ resolveCurrentUser: jest.fn().mockResolvedValue(null) } as unknown as AuthContextService,
|
||||||
|
{ can: jest.fn() } as any,
|
||||||
|
);
|
||||||
|
const handler = decorate(() => undefined, 'product:create');
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects users without the required permission', async () => {
|
||||||
|
const guard = new PermissionGuard(
|
||||||
|
new Reflector(),
|
||||||
|
{ resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any,
|
||||||
|
{ can: jest.fn().mockResolvedValue(false) } as any,
|
||||||
|
);
|
||||||
|
const handler = decorate(() => undefined, 'product:delete');
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes params-derived resource scope to the permission service', async () => {
|
||||||
|
const can = jest.fn().mockResolvedValue(true);
|
||||||
|
const guard = new PermissionGuard(
|
||||||
|
new Reflector(),
|
||||||
|
{ resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any,
|
||||||
|
{ can } as any,
|
||||||
|
);
|
||||||
|
const handler = decorate(() => undefined, 'version.devtask:manage', {
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
projectIdParam: 'projectId',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextFor(handler, {
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
}))).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(can).toHaveBeenCalledWith(
|
||||||
|
{ id: 'user-1', roleId: 'role-dev' },
|
||||||
|
'version.devtask:manage',
|
||||||
|
{ productId: 'product-1', projectId: 'project-1', versionId: 'version-1' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function decorate(handler: Function, permission: string, scope?: {
|
||||||
|
productIdParam?: string;
|
||||||
|
projectIdParam?: string;
|
||||||
|
versionIdParam?: string;
|
||||||
|
}) {
|
||||||
|
RequirePermission(permission, scope)(handler as any, undefined as any, undefined as any);
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextFor(handler: Function, params: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
getHandler: () => handler,
|
||||||
|
getClass: () => class TestController {},
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ params, headers: {} }),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
34
apps/server/src/common/auth/permission.guard.ts
Normal file
34
apps/server/src/common/auth/permission.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthContextService, type AuthenticatedRequest } from './auth-context.service';
|
||||||
|
import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from './permission.decorator';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly authContext: AuthContextService,
|
||||||
|
private readonly permissions: PermissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const metadata = this.reflector.getAllAndOverride<RequiredPermissionMetadata>(PERMISSION_METADATA_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!metadata) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest & { params?: Record<string, string> }>();
|
||||||
|
const user = await this.authContext.resolveCurrentUser(request);
|
||||||
|
if (!user) throw new UnauthorizedException('Authentication required');
|
||||||
|
|
||||||
|
const allowed = await this.permissions.can(user, metadata.permission, {
|
||||||
|
productId: metadata.productIdParam ? request.params?.[metadata.productIdParam] : undefined,
|
||||||
|
projectId: metadata.projectIdParam ? request.params?.[metadata.projectIdParam] : undefined,
|
||||||
|
versionId: metadata.versionIdParam ? request.params?.[metadata.versionIdParam] : undefined,
|
||||||
|
});
|
||||||
|
if (!allowed) throw new ForbiddenException(`Missing permission: ${metadata.permission}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
apps/server/src/common/auth/permission.service.spec.ts
Normal file
83
apps/server/src/common/auth/permission.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
import type { CurrentUser } from './auth-context.service';
|
||||||
|
|
||||||
|
describe('PermissionService', () => {
|
||||||
|
const versionFindUnique = jest.fn();
|
||||||
|
const projectMemberFindUnique = jest.fn();
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
version: { findUnique: versionFindUnique },
|
||||||
|
projectMember: { findUnique: projectMemberFindUnique },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const service = new PermissionService(prisma);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the built-in super admin wildcard for any permission without a scope lookup', async () => {
|
||||||
|
const user = currentUser({ id: 'm-8', roleId: 'role-admin' });
|
||||||
|
|
||||||
|
await expect(service.can(user, 'audit:view', { versionId: 'version-1' })).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(versionFindUnique).not.toHaveBeenCalled();
|
||||||
|
expect(projectMemberFindUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires scoped users to belong to the project resolved from a version', 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);
|
||||||
|
|
||||||
|
expect(versionFindUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'version-1' },
|
||||||
|
select: { id: true, projectId: true, members: true },
|
||||||
|
});
|
||||||
|
expect(projectMemberFindUnique).toHaveBeenCalledWith({
|
||||||
|
where: { projectId_userId: { projectId: 'project-1', userId: 'dev-1' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a version member when no project membership exists', async () => {
|
||||||
|
versionFindUnique.mockResolvedValue({
|
||||||
|
id: 'version-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
members: [{ id: 'tester-1', name: 'Tester One' }],
|
||||||
|
});
|
||||||
|
projectMemberFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.can(currentUser({ id: 'tester-1', name: 'Tester One', roleId: 'role-test' }), 'version.testcase:manage', {
|
||||||
|
versionId: 'version-1',
|
||||||
|
})).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies users who have a global role permission but are outside the resource scope', async () => {
|
||||||
|
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
|
||||||
|
projectMemberFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', {
|
||||||
|
versionId: 'version-1',
|
||||||
|
})).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws explicit auth exceptions for assertion callers', async () => {
|
||||||
|
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function currentUser(overrides: Partial<CurrentUser>): CurrentUser {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
name: 'User One',
|
||||||
|
username: 'user.one',
|
||||||
|
roleId: 'role-dev',
|
||||||
|
email: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
161
apps/server/src/common/auth/permission.service.ts
Normal file
161
apps/server/src/common/auth/permission.service.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import type { CurrentUser } from './auth-context.service';
|
||||||
|
|
||||||
|
export interface ResourceScope {
|
||||||
|
productId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
versionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedScope extends ResourceScope {
|
||||||
|
versionMembers?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_ONLY = ['product:view', 'project:view', 'version:view', 'requirement:view', 'version.req:view'];
|
||||||
|
|
||||||
|
const SYSTEM_ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
|
'role-admin': ['*'],
|
||||||
|
'role-pm': [
|
||||||
|
...std4('product'),
|
||||||
|
...std4('project'),
|
||||||
|
...std4('version'),
|
||||||
|
...std4('requirement'),
|
||||||
|
'version.req:view', 'version.req:manage',
|
||||||
|
'version.product_plan:view', 'version.product_plan:manage',
|
||||||
|
'xiaobao.warning:view', 'xiaobao.warning:manage',
|
||||||
|
'overtime:view', 'member:view', 'role:view',
|
||||||
|
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
|
||||||
|
'version.testcase:view', 'version.bug:view',
|
||||||
|
],
|
||||||
|
'role-dev': [
|
||||||
|
...VIEW_ONLY,
|
||||||
|
'version.devtask:view', 'version.devtask:manage',
|
||||||
|
'version.bug:view', 'version.bug:edit',
|
||||||
|
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.testcase:view',
|
||||||
|
'xiaobao.warning:view',
|
||||||
|
'overtime:view', 'overtime:create',
|
||||||
|
],
|
||||||
|
'role-test': [
|
||||||
|
...VIEW_ONLY,
|
||||||
|
'version.testcase:view', 'version.testcase:manage',
|
||||||
|
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
|
||||||
|
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.devtask:view',
|
||||||
|
'xiaobao.warning:view',
|
||||||
|
'overtime:view', 'overtime:create',
|
||||||
|
],
|
||||||
|
'role-design': [
|
||||||
|
...VIEW_ONLY,
|
||||||
|
'version.ui_plan:view', 'version.ui_plan:manage',
|
||||||
|
'version.research:view', 'version.product_plan:view', 'version.devtask:view',
|
||||||
|
'version.testcase:view', 'version.bug:view',
|
||||||
|
'xiaobao.warning:view',
|
||||||
|
'overtime:view', 'overtime:create',
|
||||||
|
],
|
||||||
|
'role-lead': [
|
||||||
|
...VIEW_ONLY,
|
||||||
|
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view',
|
||||||
|
'version.devtask:view', 'version.devtask:manage',
|
||||||
|
'version.testcase:view', 'version.testcase:manage',
|
||||||
|
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
|
||||||
|
'xiaobao.warning:view',
|
||||||
|
'overtime:view', 'overtime:create', 'overtime:export',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECT_ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
|
owner: ['*'],
|
||||||
|
admin: ['*'],
|
||||||
|
member: [
|
||||||
|
'project:view',
|
||||||
|
'version:view',
|
||||||
|
'requirement:view',
|
||||||
|
'version.req:view',
|
||||||
|
'version.research:view', 'version.research:manage',
|
||||||
|
'version.product_plan:view', 'version.product_plan:manage',
|
||||||
|
'version.ui_plan:view', 'version.ui_plan:manage',
|
||||||
|
'version.devtask:view', 'version.devtask:manage',
|
||||||
|
'version.testcase:view', 'version.testcase:manage',
|
||||||
|
'version.bug:view', 'version.bug:create', 'version.bug:edit',
|
||||||
|
'overtime:view', 'overtime:create',
|
||||||
|
'xiaobao.warning:view',
|
||||||
|
],
|
||||||
|
viewer: [
|
||||||
|
'project:view',
|
||||||
|
'version:view',
|
||||||
|
'requirement:view',
|
||||||
|
'version.req:view',
|
||||||
|
'version.research:view',
|
||||||
|
'version.product_plan:view',
|
||||||
|
'version.ui_plan:view',
|
||||||
|
'version.devtask:view',
|
||||||
|
'version.testcase:view',
|
||||||
|
'version.bug:view',
|
||||||
|
'xiaobao.warning:view',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@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 (systemPermissions.includes('*')) return true;
|
||||||
|
|
||||||
|
const hasSystemPermission = systemPermissions.includes(permission);
|
||||||
|
const hasScopedResource = Boolean(scope.projectId || scope.versionId);
|
||||||
|
if (!hasScopedResource) return hasSystemPermission;
|
||||||
|
|
||||||
|
const resolvedScope = await this.resolveScope(scope);
|
||||||
|
const projectMember = resolvedScope.projectId ? await this.prisma.projectMember.findUnique({
|
||||||
|
where: { projectId_userId: { projectId: resolvedScope.projectId, userId: user.id } },
|
||||||
|
}) : null;
|
||||||
|
|
||||||
|
if (projectMember && roleAllows(PROJECT_ROLE_PERMISSIONS[projectMember.role] ?? [], permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertCan(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<void> {
|
||||||
|
if (!user) throw new UnauthorizedException('Authentication required');
|
||||||
|
if (!(await this.can(user, permission, scope))) {
|
||||||
|
throw new ForbiddenException(`Missing permission: ${permission}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveScope(scope: ResourceScope): Promise<ResolvedScope> {
|
||||||
|
if (!scope.versionId || scope.projectId) return scope;
|
||||||
|
const version = await this.prisma.version.findUnique({
|
||||||
|
where: { id: scope.versionId },
|
||||||
|
select: { id: true, projectId: true, members: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...scope,
|
||||||
|
projectId: version?.projectId ?? scope.projectId,
|
||||||
|
versionMembers: version?.members,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function std4(module: string): string[] {
|
||||||
|
return [`${module}:view`, `${module}:create`, `${module}:edit`, `${module}:delete`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleAllows(permissions: string[], permission: string): boolean {
|
||||||
|
return permissions.includes('*') || permissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVersionMember(user: CurrentUser, members: unknown): boolean {
|
||||||
|
if (!Array.isArray(members)) return false;
|
||||||
|
return members.some((member) => {
|
||||||
|
if (typeof member === 'string') return member === user.id || member === user.name;
|
||||||
|
if (!member || typeof member !== 'object') return false;
|
||||||
|
const record = member as { id?: unknown; userId?: unknown; name?: unknown };
|
||||||
|
return record.id === user.id || record.userId === user.id || record.name === user.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user