From 64f49c512f8bb8203392ce6d3d149bdc04ec6547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:13:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=A2=9E=E5=8A=A0=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E6=9D=83=E9=99=90=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 2 + .../src/common/auth/auth-context.service.ts | 62 +++++++ apps/server/src/common/auth/auth.module.ts | 11 ++ .../src/common/auth/current-user.decorator.ts | 9 + .../src/common/auth/permission.decorator.ts | 17 ++ .../src/common/auth/permission.guard.spec.ts | 74 ++++++++ .../src/common/auth/permission.guard.ts | 34 ++++ .../common/auth/permission.service.spec.ts | 83 +++++++++ .../src/common/auth/permission.service.ts | 161 ++++++++++++++++++ 9 files changed, 453 insertions(+) create mode 100644 apps/server/src/common/auth/auth-context.service.ts create mode 100644 apps/server/src/common/auth/auth.module.ts create mode 100644 apps/server/src/common/auth/current-user.decorator.ts create mode 100644 apps/server/src/common/auth/permission.decorator.ts create mode 100644 apps/server/src/common/auth/permission.guard.spec.ts create mode 100644 apps/server/src/common/auth/permission.guard.ts create mode 100644 apps/server/src/common/auth/permission.service.spec.ts create mode 100644 apps/server/src/common/auth/permission.service.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index bf16a90..92cb2f3 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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, diff --git a/apps/server/src/common/auth/auth-context.service.ts b/apps/server/src/common/auth/auth-context.service.ts new file mode 100644 index 0000000..41ec371 --- /dev/null +++ b/apps/server/src/common/auth/auth-context.service.ts @@ -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; + currentUser?: CurrentUser | null; +} + +@Injectable() +export class AuthContextService { + constructor(private readonly prisma: PrismaService) {} + + async resolveCurrentUser(request: AuthenticatedRequest): Promise { + 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 ?? ''; +} diff --git a/apps/server/src/common/auth/auth.module.ts b/apps/server/src/common/auth/auth.module.ts new file mode 100644 index 0000000..346b2ed --- /dev/null +++ b/apps/server/src/common/auth/auth.module.ts @@ -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 {} diff --git a/apps/server/src/common/auth/current-user.decorator.ts b/apps/server/src/common/auth/current-user.decorator.ts new file mode 100644 index 0000000..041726d --- /dev/null +++ b/apps/server/src/common/auth/current-user.decorator.ts @@ -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(); + return request.currentUser ?? null; + }, +); diff --git a/apps/server/src/common/auth/permission.decorator.ts b/apps/server/src/common/auth/permission.decorator.ts new file mode 100644 index 0000000..faf12e9 --- /dev/null +++ b/apps/server/src/common/auth/permission.decorator.ts @@ -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 }); +} diff --git a/apps/server/src/common/auth/permission.guard.spec.ts b/apps/server/src/common/auth/permission.guard.spec.ts new file mode 100644 index 0000000..59aca73 --- /dev/null +++ b/apps/server/src/common/auth/permission.guard.spec.ts @@ -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 = {}) { + return { + getHandler: () => handler, + getClass: () => class TestController {}, + switchToHttp: () => ({ + getRequest: () => ({ params, headers: {} }), + }), + } as any; +} diff --git a/apps/server/src/common/auth/permission.guard.ts b/apps/server/src/common/auth/permission.guard.ts new file mode 100644 index 0000000..558f802 --- /dev/null +++ b/apps/server/src/common/auth/permission.guard.ts @@ -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 { + const metadata = this.reflector.getAllAndOverride(PERMISSION_METADATA_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!metadata) return true; + + const request = context.switchToHttp().getRequest }>(); + 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; + } +} diff --git a/apps/server/src/common/auth/permission.service.spec.ts b/apps/server/src/common/auth/permission.service.spec.ts new file mode 100644 index 0000000..64135de --- /dev/null +++ b/apps/server/src/common/auth/permission.service.spec.ts @@ -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 { + return { + id: 'user-1', + name: 'User One', + username: 'user.one', + roleId: 'role-dev', + email: '', + ...overrides, + }; +} diff --git a/apps/server/src/common/auth/permission.service.ts b/apps/server/src/common/auth/permission.service.ts new file mode 100644 index 0000000..c454b87 --- /dev/null +++ b/apps/server/src/common/auth/permission.service.ts @@ -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 = { + '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 = { + 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 { + 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 { + 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 { + 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; + }); +}