feat(auth): 增加服务端权限上下文

This commit is contained in:
2026-07-08 16:13:55 +08:00
parent 8043fcf293
commit 64f49c512f
9 changed files with 453 additions and 0 deletions

View File

@@ -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,

View 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 ?? '';
}

View 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 {}

View 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;
},
);

View 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 });
}

View 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;
}

View 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;
}
}

View 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,
};
}

View 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;
});
}