feat(auth): 增加服务端权限上下文
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
||||
import { AuthModule } from './common/auth/auth.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProductModule } from './modules/product/product.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
@@ -24,6 +25,7 @@ import { HealthModule } from './modules/health/health.module';
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
ProductModule,
|
||||
ProjectModule,
|
||||
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