From 18380edda89652a5b012bd4b441405024ea66f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:29:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=E6=94=B6=E5=8F=A3=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=E5=86=99=E6=8E=A5=E5=8F=A3=E6=9D=83=E9=99=90=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/audit/audit-mutation.decorator.ts | 14 +++ .../audit/audit-mutation.interceptor.spec.ts | 59 ++++++++++++ .../audit/audit-mutation.interceptor.ts | 90 +++++++++++++++++++ .../audit/protected-mutation.decorator.ts | 18 ++++ .../src/common/auth/auth-context.service.ts | 10 ++- .../src/common/auth/permission.decorator.ts | 3 + .../src/common/auth/permission.guard.ts | 20 ++++- apps/server/src/modules/audit/audit.module.ts | 8 +- apps/server/src/modules/bug/bug.controller.ts | 30 +++++++ .../modules/dev-task/dev-task.controller.ts | 36 ++++++++ .../src/modules/member/member.controller.ts | 12 +++ .../modules/overtime/overtime.controller.ts | 21 +++++ .../src/modules/product/product.controller.ts | 14 +++ .../src/modules/project/project.controller.ts | 20 +++++ .../requirement/requirement.controller.ts | 24 +++++ .../task-category/task-category.controller.ts | 12 +++ .../task-worklog/task-worklog.controller.ts | 11 +++ .../modules/test-case/test-case.controller.ts | 29 ++++++ .../v25-domain-mutation-contracts.spec.ts | 77 ++++++++++++++++ .../version-plan/version-plan.controller.ts | 24 +++++ .../src/modules/version/version.controller.ts | 26 ++++++ .../work-activity/work-activity.controller.ts | 13 +++ apps/web/lib/api.test.ts | 38 ++++++++ apps/web/lib/api.ts | 39 +++++++- 24 files changed, 638 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/common/audit/audit-mutation.decorator.ts create mode 100644 apps/server/src/common/audit/audit-mutation.interceptor.spec.ts create mode 100644 apps/server/src/common/audit/audit-mutation.interceptor.ts create mode 100644 apps/server/src/common/audit/protected-mutation.decorator.ts create mode 100644 apps/server/src/modules/v25-domain-mutation-contracts.spec.ts diff --git a/apps/server/src/common/audit/audit-mutation.decorator.ts b/apps/server/src/common/audit/audit-mutation.decorator.ts new file mode 100644 index 0000000..15ebe90 --- /dev/null +++ b/apps/server/src/common/audit/audit-mutation.decorator.ts @@ -0,0 +1,14 @@ +import { SetMetadata } from '@nestjs/common'; +import type { PermissionScopeOptions } from '../auth/permission.decorator'; + +export const AUDIT_MUTATION_METADATA_KEY = 'ftb:audit-mutation'; + +export interface AuditMutationMetadata extends PermissionScopeOptions { + action: string; + entityType: string; + entityIdParam?: string; +} + +export function AuditMutation(metadata: AuditMutationMetadata) { + return SetMetadata(AUDIT_MUTATION_METADATA_KEY, metadata); +} diff --git a/apps/server/src/common/audit/audit-mutation.interceptor.spec.ts b/apps/server/src/common/audit/audit-mutation.interceptor.spec.ts new file mode 100644 index 0000000..cc14673 --- /dev/null +++ b/apps/server/src/common/audit/audit-mutation.interceptor.spec.ts @@ -0,0 +1,59 @@ +import { lastValueFrom, of } from 'rxjs'; +import { AuditMutationInterceptor } from './audit-mutation.interceptor'; +import { AuditMutation } from './audit-mutation.decorator'; + +describe('AuditMutationInterceptor', () => { + it('writes an audit event after a successful mutation response', async () => { + const record = jest.fn().mockResolvedValue({ id: 'audit-1' }); + const resolveCurrentUser = jest.fn().mockResolvedValue({ id: 'm-8', name: '超级管理员', roleId: 'role-admin' }); + const interceptor = new AuditMutationInterceptor( + new (jest.requireActual('@nestjs/core').Reflector)(), + { record } as any, + { resolveCurrentUser } as any, + ); + const handler = decorate(() => undefined); + + const result = await lastValueFrom(interceptor.intercept(contextFor(handler), { + handle: () => of({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }), + } as any)); + + expect(result).toEqual({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }); + expect(record).toHaveBeenCalledWith({ + actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' }, + action: 'dev_task.update', + entityType: 'dev_task', + entityId: 'task-1', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + scope: { productId: 'product-1', projectId: 'project-1', versionId: 'version-1' }, + after: { item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }, + metadata: { route: 'PATCH /versions/version-1/dev-tasks/task-1' }, + }); + }); +}); + +function decorate(handler: Function) { + AuditMutation({ + action: 'dev_task.update', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + })(handler as any, undefined as any, undefined as any); + return handler; +} + +function contextFor(handler: Function) { + const request = { + method: 'PATCH', + originalUrl: '/versions/version-1/dev-tasks/task-1', + params: { id: 'task-1', versionId: 'version-1' }, + body: {}, + headers: {}, + }; + return { + getHandler: () => handler, + getClass: () => class TestController {}, + switchToHttp: () => ({ getRequest: () => request }), + } as any; +} diff --git a/apps/server/src/common/audit/audit-mutation.interceptor.ts b/apps/server/src/common/audit/audit-mutation.interceptor.ts new file mode 100644 index 0000000..eda5e43 --- /dev/null +++ b/apps/server/src/common/audit/audit-mutation.interceptor.ts @@ -0,0 +1,90 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { mergeMap, Observable } from 'rxjs'; +import { AuthContextService, type AuthenticatedRequest } from '../auth/auth-context.service'; +import { AuditService } from '../../modules/audit/audit.service'; +import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from './audit-mutation.decorator'; + +type MutationRequest = AuthenticatedRequest & { + method?: string; + originalUrl?: string; + url?: string; + params?: Record; + body?: Record; +}; + +@Injectable() +export class AuditMutationInterceptor implements NestInterceptor { + constructor( + private readonly reflector: Reflector, + private readonly audit: AuditService, + private readonly authContext: AuthContextService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const metadata = this.reflector.getAllAndOverride(AUDIT_MUTATION_METADATA_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!metadata) return next.handle(); + + const request = context.switchToHttp().getRequest(); + return next.handle().pipe(mergeMap(async (result) => { + const actor = await this.authContext.resolveCurrentUser(request); + const entity = extractEntity(result); + const scope = resolveScope(metadata, request, entity); + await this.audit.record({ + actor, + action: metadata.action, + entityType: metadata.entityType, + entityId: resolveEntityId(metadata, request, entity), + productId: scope.productId, + projectId: scope.projectId, + versionId: scope.versionId, + scope, + after: result, + metadata: { route: `${request.method ?? 'UNKNOWN'} ${request.originalUrl ?? request.url ?? ''}`.trim() }, + }); + return result; + })); + } +} + +function extractEntity(result: unknown): Record | undefined { + if (!result || typeof result !== 'object') return undefined; + const record = result as Record; + if (record.item && typeof record.item === 'object') return record.item as Record; + if (Array.isArray(record.items) && record.items[0] && typeof record.items[0] === 'object') return record.items[0] as Record; + return record; +} + +function resolveEntityId(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record | undefined) { + const fromParam = metadata.entityIdParam ? request.params?.[metadata.entityIdParam] : undefined; + const fromEntity = entity?.id; + return fromParam ?? (typeof fromEntity === 'string' ? fromEntity : 'unknown'); +} + +function resolveScope(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record | undefined) { + return compact({ + productId: scopedValue(metadata.productIdParam, metadata.productIdBody, 'productId', request, entity), + projectId: scopedValue(metadata.projectIdParam, metadata.projectIdBody, 'projectId', request, entity), + versionId: scopedValue(metadata.versionIdParam, metadata.versionIdBody, 'versionId', request, entity), + }); +} + +function scopedValue( + paramKey: string | undefined, + bodyKey: string | undefined, + resultKey: string, + request: MutationRequest, + entity: Record | undefined, +): string | undefined { + const value = (paramKey ? request.params?.[paramKey] : undefined) + ?? (bodyKey ? request.body?.[bodyKey] : undefined) + ?? entity?.[resultKey]; + return typeof value === 'string' && value ? value : undefined; +} + +function compact>(value: T): { [K in keyof T]?: string } { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item)) as { [K in keyof T]?: string }; +} diff --git a/apps/server/src/common/audit/protected-mutation.decorator.ts b/apps/server/src/common/audit/protected-mutation.decorator.ts new file mode 100644 index 0000000..086566c --- /dev/null +++ b/apps/server/src/common/audit/protected-mutation.decorator.ts @@ -0,0 +1,18 @@ +import { applyDecorators, UseGuards, UseInterceptors } from '@nestjs/common'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission, type PermissionScopeOptions } from '../auth/permission.decorator'; +import { AuditMutation, type AuditMutationMetadata } from './audit-mutation.decorator'; +import { AuditMutationInterceptor } from './audit-mutation.interceptor'; + +export function ProtectedMutation( + permission: string, + scope: PermissionScopeOptions, + audit: AuditMutationMetadata, +) { + return applyDecorators( + UseGuards(PermissionGuard), + RequirePermission(permission, scope), + UseInterceptors(AuditMutationInterceptor), + AuditMutation(audit), + ); +} diff --git a/apps/server/src/common/auth/auth-context.service.ts b/apps/server/src/common/auth/auth-context.service.ts index 41ec371..861b0b1 100644 --- a/apps/server/src/common/auth/auth-context.service.ts +++ b/apps/server/src/common/auth/auth-context.service.ts @@ -29,7 +29,7 @@ export class AuthContextService { const headerUser: CurrentUser = { id: userId, - name: headerValue(request, 'x-ftb-user-name'), + name: decodeHeader(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'), @@ -60,3 +60,11 @@ function headerValue(request: AuthenticatedRequest, name: string): string { if (Array.isArray(value)) return value[0] ?? ''; return value ?? ''; } + +function decodeHeader(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} diff --git a/apps/server/src/common/auth/permission.decorator.ts b/apps/server/src/common/auth/permission.decorator.ts index faf12e9..9ccf1c0 100644 --- a/apps/server/src/common/auth/permission.decorator.ts +++ b/apps/server/src/common/auth/permission.decorator.ts @@ -6,6 +6,9 @@ export interface PermissionScopeOptions { productIdParam?: string; projectIdParam?: string; versionIdParam?: string; + productIdBody?: string; + projectIdBody?: string; + versionIdBody?: string; } export interface RequiredPermissionMetadata extends PermissionScopeOptions { diff --git a/apps/server/src/common/auth/permission.guard.ts b/apps/server/src/common/auth/permission.guard.ts index 558f802..657ce51 100644 --- a/apps/server/src/common/auth/permission.guard.ts +++ b/apps/server/src/common/auth/permission.guard.ts @@ -19,16 +19,28 @@ export class PermissionGuard implements CanActivate { ]); if (!metadata) return true; - const request = context.switchToHttp().getRequest }>(); + const request = context.switchToHttp().getRequest; + body?: Record; + }>(); 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, + productId: scopedValue(request, metadata.productIdParam, metadata.productIdBody), + projectId: scopedValue(request, metadata.projectIdParam, metadata.projectIdBody), + versionId: scopedValue(request, metadata.versionIdParam, metadata.versionIdBody), }); if (!allowed) throw new ForbiddenException(`Missing permission: ${metadata.permission}`); return true; } } + +function scopedValue( + request: { params?: Record; body?: Record }, + paramKey?: string, + bodyKey?: string, +): string | undefined { + const value = (paramKey ? request.params?.[paramKey] : undefined) ?? (bodyKey ? request.body?.[bodyKey] : undefined); + return typeof value === 'string' && value ? value : undefined; +} diff --git a/apps/server/src/modules/audit/audit.module.ts b/apps/server/src/modules/audit/audit.module.ts index 3695111..3521853 100644 --- a/apps/server/src/modules/audit/audit.module.ts +++ b/apps/server/src/modules/audit/audit.module.ts @@ -1,10 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; +import { AuditMutationInterceptor } from '../../common/audit/audit-mutation.interceptor'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; +@Global() @Module({ controllers: [AuditController], - providers: [AuditService], - exports: [AuditService], + providers: [AuditService, AuditMutationInterceptor], + exports: [AuditService, AuditMutationInterceptor], }) export class AuditModule {} diff --git a/apps/server/src/modules/bug/bug.controller.ts b/apps/server/src/modules/bug/bug.controller.ts index 3c5a4a3..12a731a 100644 --- a/apps/server/src/modules/bug/bug.controller.ts +++ b/apps/server/src/modules/bug/bug.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { CreateBugDto } from './dto/create-bug.dto'; import { UpdateBugDto } from './dto/update-bug.dto'; import { BugService } from './bug.service'; @@ -8,6 +9,11 @@ export class BugController { constructor(private readonly bugService: BugService) {} @Post() + @ProtectedMutation('version.bug:create', { versionIdParam: 'versionId' }, { + action: 'bug.create', + entityType: 'bug', + versionIdParam: 'versionId', + }) create(@Param('versionId') versionId: string, @Body() dto: CreateBugDto) { return this.bugService.create(versionId, dto); } @@ -18,11 +24,23 @@ export class BugController { } @Patch(':id') + @ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, { + action: 'bug.update', + entityType: 'bug', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateBugDto) { return this.bugService.update(versionId, id, dto); } @Patch(':id/status') + @ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, { + action: 'bug.status', + entityType: 'bug', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) updateStatus( @Param('versionId') versionId: string, @Param('id') id: string, @@ -33,6 +51,12 @@ export class BugController { } @Patch(':id/transfer') + @ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, { + action: 'bug.transfer', + entityType: 'bug', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) transfer( @Param('versionId') versionId: string, @Param('id') id: string, @@ -43,6 +67,12 @@ export class BugController { } @Delete(':id') + @ProtectedMutation('version.bug:delete', { versionIdParam: 'versionId' }, { + action: 'bug.delete', + entityType: 'bug', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) remove(@Param('versionId') versionId: string, @Param('id') id: string) { return this.bugService.remove(versionId, id); } diff --git a/apps/server/src/modules/dev-task/dev-task.controller.ts b/apps/server/src/modules/dev-task/dev-task.controller.ts index 37234e4..3942e0a 100644 --- a/apps/server/src/modules/dev-task/dev-task.controller.ts +++ b/apps/server/src/modules/dev-task/dev-task.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { CreateDevTaskDto } from './dto/create-dev-task.dto'; import { UpdateDevTaskDto } from './dto/update-dev-task.dto'; import { DevTaskService } from './dev-task.service'; @@ -8,6 +9,11 @@ export class DevTaskController { constructor(private readonly devTaskService: DevTaskService) {} @Post() + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.create', + entityType: 'dev_task', + versionIdParam: 'versionId', + }) create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) { return this.devTaskService.create(versionId, dto); } @@ -18,11 +24,23 @@ export class DevTaskController { } @Patch(':id') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.update', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateDevTaskDto) { return this.devTaskService.update(versionId, id, dto); } @Patch(':id/status') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.status', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) updateStatus( @Param('versionId') versionId: string, @Param('id') id: string, @@ -32,6 +50,12 @@ export class DevTaskController { } @Patch(':id/block') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.block', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) setBlocked( @Param('versionId') versionId: string, @Param('id') id: string, @@ -42,6 +66,12 @@ export class DevTaskController { } @Patch(':id/transfer') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.transfer', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) transfer( @Param('versionId') versionId: string, @Param('id') id: string, @@ -51,6 +81,12 @@ export class DevTaskController { } @Delete(':id') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.delete', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) remove(@Param('versionId') versionId: string, @Param('id') id: string) { return this.devTaskService.remove(versionId, id); } diff --git a/apps/server/src/modules/member/member.controller.ts b/apps/server/src/modules/member/member.controller.ts index 88c18f4..d0a4db8 100644 --- a/apps/server/src/modules/member/member.controller.ts +++ b/apps/server/src/modules/member/member.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { CreateMemberDto } from './dto/create-member.dto'; import { UpdateMemberDto } from './dto/update-member.dto'; import { MemberService } from './member.service'; @@ -13,16 +14,27 @@ export class MemberController { } @Post() + @ProtectedMutation('member:create', {}, { action: 'member.create', entityType: 'member' }) create(@Body() dto: CreateMemberDto) { return this.memberService.create(dto); } @Patch(':id') + @ProtectedMutation('member:edit', {}, { + action: 'member.update', + entityType: 'member', + entityIdParam: 'id', + }) update(@Param('id') id: string, @Body() dto: UpdateMemberDto) { return this.memberService.update(id, dto); } @Delete(':id') + @ProtectedMutation('member:delete', {}, { + action: 'member.delete', + entityType: 'member', + entityIdParam: 'id', + }) remove(@Param('id') id: string) { return this.memberService.remove(id); } diff --git a/apps/server/src/modules/overtime/overtime.controller.ts b/apps/server/src/modules/overtime/overtime.controller.ts index f142442..fea15a6 100644 --- a/apps/server/src/modules/overtime/overtime.controller.ts +++ b/apps/server/src/modules/overtime/overtime.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { CreateOvertimeDto } from './dto/create-overtime.dto'; import { UpdateOvertimeDto } from './dto/update-overtime.dto'; import { OvertimeService } from './overtime.service'; @@ -13,16 +14,36 @@ export class OvertimeController { } @Post() + @ProtectedMutation('overtime:create', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, { + action: 'overtime.create', + entityType: 'overtime', + productIdBody: 'productId', + projectIdBody: 'projectId', + versionIdBody: 'versionId', + }) create(@Body() dto: CreateOvertimeDto) { return this.overtimeService.create(dto); } @Patch(':id') + @ProtectedMutation('overtime:create', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, { + action: 'overtime.update', + entityType: 'overtime', + entityIdParam: 'id', + productIdBody: 'productId', + projectIdBody: 'projectId', + versionIdBody: 'versionId', + }) update(@Param('id') id: string, @Body() dto: UpdateOvertimeDto) { return this.overtimeService.update(id, dto); } @Delete(':id') + @ProtectedMutation('overtime:delete', {}, { + action: 'overtime.delete', + entityType: 'overtime', + entityIdParam: 'id', + }) remove(@Param('id') id: string) { return this.overtimeService.remove(id); } diff --git a/apps/server/src/modules/product/product.controller.ts b/apps/server/src/modules/product/product.controller.ts index f4cfe06..0c261c6 100644 --- a/apps/server/src/modules/product/product.controller.ts +++ b/apps/server/src/modules/product/product.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { ProductService } from './product.service'; import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; @@ -8,6 +9,7 @@ export class ProductController { constructor(private readonly productService: ProductService) {} @Post() + @ProtectedMutation('product:create', {}, { action: 'product.create', entityType: 'product' }) create(@Body() dto: CreateProductDto) { return this.productService.create(dto); } @@ -28,11 +30,23 @@ export class ProductController { } @Patch(':id') + @ProtectedMutation('product:edit', { productIdParam: 'id' }, { + action: 'product.update', + entityType: 'product', + entityIdParam: 'id', + productIdParam: 'id', + }) update(@Param('id') id: string, @Body() dto: UpdateProductDto) { return this.productService.update(id, dto); } @Delete(':id') + @ProtectedMutation('product:delete', { productIdParam: 'id' }, { + action: 'product.delete', + entityType: 'product', + entityIdParam: 'id', + productIdParam: 'id', + }) remove(@Param('id') id: string) { return this.productService.remove(id); } diff --git a/apps/server/src/modules/project/project.controller.ts b/apps/server/src/modules/project/project.controller.ts index 0b6e6c9..d4c926b 100644 --- a/apps/server/src/modules/project/project.controller.ts +++ b/apps/server/src/modules/project/project.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { ProjectService } from './project.service'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; @@ -8,6 +9,11 @@ export class ProjectController { constructor(private readonly projectService: ProjectService) {} @Post() + @ProtectedMutation('project:create', { productIdParam: 'productId' }, { + action: 'project.create', + entityType: 'project', + productIdParam: 'productId', + }) create(@Param('productId') productId: string, @Body() dto: CreateProjectDto) { return this.projectService.create(productId, dto); } @@ -18,6 +24,13 @@ export class ProjectController { } @Patch(':projectId') + @ProtectedMutation('project:edit', { productIdParam: 'productId', projectIdParam: 'projectId' }, { + action: 'project.update', + entityType: 'project', + entityIdParam: 'projectId', + productIdParam: 'productId', + projectIdParam: 'projectId', + }) update( @Param('productId') productId: string, @Param('projectId') projectId: string, @@ -27,6 +40,13 @@ export class ProjectController { } @Delete(':projectId') + @ProtectedMutation('project:delete', { productIdParam: 'productId', projectIdParam: 'projectId' }, { + action: 'project.delete', + entityType: 'project', + entityIdParam: 'projectId', + productIdParam: 'productId', + projectIdParam: 'projectId', + }) remove(@Param('productId') productId: string, @Param('projectId') projectId: string) { return this.projectService.remove(productId, projectId); } diff --git a/apps/server/src/modules/requirement/requirement.controller.ts b/apps/server/src/modules/requirement/requirement.controller.ts index e022fd1..8a9baa2 100644 --- a/apps/server/src/modules/requirement/requirement.controller.ts +++ b/apps/server/src/modules/requirement/requirement.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { RequirementService } from './requirement.service'; import { CreateRequirementDto } from './dto/create-requirement.dto'; import { UpdateRequirementDto } from './dto/update-requirement.dto'; @@ -9,6 +10,11 @@ export class RequirementController { constructor(private readonly requirementService: RequirementService) {} @Post() + @ProtectedMutation('requirement:create', { productIdParam: 'productId' }, { + action: 'requirement.create', + entityType: 'requirement', + productIdParam: 'productId', + }) create( @Param('productId') productId: string, @Body() dto: CreateRequirementDto, @@ -51,6 +57,12 @@ export class RequirementController { } @Patch(':id') + @ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, { + action: 'requirement.update', + entityType: 'requirement', + entityIdParam: 'id', + productIdParam: 'productId', + }) update( @Param('productId') productId: string, @Param('id') id: string, @@ -60,6 +72,12 @@ export class RequirementController { } @Patch(':id/status') + @ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, { + action: 'requirement.status', + entityType: 'requirement', + entityIdParam: 'id', + productIdParam: 'productId', + }) updateStatus( @Param('productId') productId: string, @Param('id') id: string, @@ -69,6 +87,12 @@ export class RequirementController { } @Delete(':id') + @ProtectedMutation('requirement:delete', { productIdParam: 'productId' }, { + action: 'requirement.delete', + entityType: 'requirement', + entityIdParam: 'id', + productIdParam: 'productId', + }) remove( @Param('productId') productId: string, @Param('id') id: string, diff --git a/apps/server/src/modules/task-category/task-category.controller.ts b/apps/server/src/modules/task-category/task-category.controller.ts index efaa60f..6eea00d 100644 --- a/apps/server/src/modules/task-category/task-category.controller.ts +++ b/apps/server/src/modules/task-category/task-category.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { CreateTaskCategoryDto } from './dto/create-task-category.dto'; import { UpdateTaskCategoryDto } from './dto/update-task-category.dto'; import { TaskCategoryService } from './task-category.service'; @@ -13,16 +14,27 @@ export class TaskCategoryController { } @Post() + @ProtectedMutation('task-category:manage', {}, { action: 'task_category.create', entityType: 'task_category' }) create(@Body() dto: CreateTaskCategoryDto) { return this.taskCategoryService.create(dto); } @Patch(':id') + @ProtectedMutation('task-category:manage', {}, { + action: 'task_category.update', + entityType: 'task_category', + entityIdParam: 'id', + }) update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) { return this.taskCategoryService.update(id, dto); } @Delete(':id') + @ProtectedMutation('task-category:manage', {}, { + action: 'task_category.delete', + entityType: 'task_category', + entityIdParam: 'id', + }) remove(@Param('id') id: string) { return this.taskCategoryService.remove(id); } diff --git a/apps/server/src/modules/task-worklog/task-worklog.controller.ts b/apps/server/src/modules/task-worklog/task-worklog.controller.ts index c6876d8..c7d2ac8 100644 --- a/apps/server/src/modules/task-worklog/task-worklog.controller.ts +++ b/apps/server/src/modules/task-worklog/task-worklog.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto'; import { TaskWorklogService } from './task-worklog.service'; @@ -12,11 +13,21 @@ export class TaskWorklogController { } @Post() + @ProtectedMutation('version.devtask:manage', { versionIdBody: 'versionId' }, { + action: 'task_worklog.create', + entityType: 'task_worklog', + versionIdBody: 'versionId', + }) create(@Body() dto: CreateTaskWorklogDto) { return this.taskWorklogService.create(dto); } @Delete(':id') + @ProtectedMutation('version.devtask:manage', {}, { + action: 'task_worklog.delete', + entityType: 'task_worklog', + entityIdParam: 'id', + }) remove(@Param('id') id: string) { return this.taskWorklogService.remove(id); } diff --git a/apps/server/src/modules/test-case/test-case.controller.ts b/apps/server/src/modules/test-case/test-case.controller.ts index 1ac78a2..befdc01 100644 --- a/apps/server/src/modules/test-case/test-case.controller.ts +++ b/apps/server/src/modules/test-case/test-case.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { CreateTestCaseDto } from './dto/create-test-case.dto'; import { UpdateTestCaseDto } from './dto/update-test-case.dto'; import { TestCaseService } from './test-case.service'; @@ -8,11 +9,21 @@ export class TestCaseController { constructor(private readonly testCaseService: TestCaseService) {} @Post() + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.create', + entityType: 'test_case', + versionIdParam: 'versionId', + }) create(@Param('versionId') versionId: string, @Body() dto: CreateTestCaseDto) { return this.testCaseService.create(versionId, dto); } @Post('batch') + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.batch_create', + entityType: 'test_case', + versionIdParam: 'versionId', + }) createMany(@Param('versionId') versionId: string, @Body('items') items: CreateTestCaseDto[]) { return this.testCaseService.createMany(versionId, items ?? []); } @@ -23,11 +34,23 @@ export class TestCaseController { } @Patch(':id') + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.update', + entityType: 'test_case', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateTestCaseDto) { return this.testCaseService.update(versionId, id, dto); } @Patch(':id/status') + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.status', + entityType: 'test_case', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) updateStatus( @Param('versionId') versionId: string, @Param('id') id: string, @@ -37,6 +60,12 @@ export class TestCaseController { } @Delete(':id') + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.delete', + entityType: 'test_case', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) remove(@Param('versionId') versionId: string, @Param('id') id: string) { return this.testCaseService.remove(versionId, id); } diff --git a/apps/server/src/modules/v25-domain-mutation-contracts.spec.ts b/apps/server/src/modules/v25-domain-mutation-contracts.spec.ts new file mode 100644 index 0000000..e793dc7 --- /dev/null +++ b/apps/server/src/modules/v25-domain-mutation-contracts.spec.ts @@ -0,0 +1,77 @@ +import { Reflector } from '@nestjs/core'; +import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from '../common/auth/permission.decorator'; +import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from '../common/audit/audit-mutation.decorator'; +import { ProductController } from './product/product.controller'; +import { ProjectController } from './project/project.controller'; +import { VersionController } from './version/version.controller'; +import { RequirementController } from './requirement/requirement.controller'; +import { VersionPlanController } from './version-plan/version-plan.controller'; +import { DevTaskController } from './dev-task/dev-task.controller'; +import { TestCaseController } from './test-case/test-case.controller'; +import { BugController } from './bug/bug.controller'; +import { MemberController } from './member/member.controller'; +import { TaskCategoryController } from './task-category/task-category.controller'; +import { TaskWorklogController } from './task-worklog/task-worklog.controller'; +import { OvertimeController } from './overtime/overtime.controller'; +import { WorkActivityController } from './work-activity/work-activity.controller'; + +describe('V2.5 domain mutation contracts', () => { + const reflector = new Reflector(); + + const cases: Array<[Function, string, RequiredPermissionMetadata, AuditMutationMetadata]> = [ + [ProductController, 'create', { permission: 'product:create' }, { action: 'product.create', entityType: 'product' }], + [ProductController, 'update', { permission: 'product:edit', productIdParam: 'id' }, { action: 'product.update', entityType: 'product', entityIdParam: 'id', productIdParam: 'id' }], + [ProductController, 'remove', { permission: 'product:delete', productIdParam: 'id' }, { action: 'product.delete', entityType: 'product', entityIdParam: 'id', productIdParam: 'id' }], + [ProjectController, 'create', { permission: 'project:create', productIdParam: 'productId' }, { action: 'project.create', entityType: 'project', productIdParam: 'productId' }], + [ProjectController, 'update', { permission: 'project:edit', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'project.update', entityType: 'project', entityIdParam: 'projectId', productIdParam: 'productId', projectIdParam: 'projectId' }], + [ProjectController, 'remove', { permission: 'project:delete', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'project.delete', entityType: 'project', entityIdParam: 'projectId', productIdParam: 'productId', projectIdParam: 'projectId' }], + [VersionController, 'create', { permission: 'version:create', productIdParam: 'productId' }, { action: 'version.create', entityType: 'version', productIdParam: 'productId' }], + [VersionController, 'createForProject', { permission: 'version:create', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'version.create', entityType: 'version', productIdParam: 'productId', projectIdParam: 'projectId' }], + [VersionController, 'update', { permission: 'version:edit', productIdParam: 'productId', versionIdParam: 'versionId' }, { action: 'version.update', entityType: 'version', entityIdParam: 'versionId', productIdParam: 'productId', versionIdParam: 'versionId' }], + [VersionController, 'remove', { permission: 'version:delete', productIdParam: 'productId', versionIdParam: 'versionId' }, { action: 'version.delete', entityType: 'version', entityIdParam: 'versionId', productIdParam: 'productId', versionIdParam: 'versionId' }], + [RequirementController, 'create', { permission: 'requirement:create', productIdParam: 'productId' }, { action: 'requirement.create', entityType: 'requirement', productIdParam: 'productId' }], + [RequirementController, 'update', { permission: 'requirement:edit', productIdParam: 'productId' }, { action: 'requirement.update', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }], + [RequirementController, 'updateStatus', { permission: 'requirement:edit', productIdParam: 'productId' }, { action: 'requirement.status', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }], + [RequirementController, 'remove', { permission: 'requirement:delete', productIdParam: 'productId' }, { action: 'requirement.delete', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }], + [VersionPlanController, 'create', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.create', entityType: 'version_plan', versionIdParam: 'versionId' }], + [VersionPlanController, 'update', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.update', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }], + [VersionPlanController, 'complete', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.complete', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }], + [VersionPlanController, 'remove', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.delete', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'create', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.create', entityType: 'dev_task', versionIdParam: 'versionId' }], + [DevTaskController, 'update', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.update', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'updateStatus', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.status', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'setBlocked', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.block', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'transfer', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.transfer', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'remove', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.delete', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [TestCaseController, 'create', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.create', entityType: 'test_case', versionIdParam: 'versionId' }], + [TestCaseController, 'createMany', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.batch_create', entityType: 'test_case', versionIdParam: 'versionId' }], + [TestCaseController, 'update', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.update', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }], + [TestCaseController, 'updateStatus', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.status', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }], + [TestCaseController, 'remove', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.delete', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }], + [BugController, 'create', { permission: 'version.bug:create', versionIdParam: 'versionId' }, { action: 'bug.create', entityType: 'bug', versionIdParam: 'versionId' }], + [BugController, 'update', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.update', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }], + [BugController, 'updateStatus', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.status', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }], + [BugController, 'transfer', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.transfer', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }], + [BugController, 'remove', { permission: 'version.bug:delete', versionIdParam: 'versionId' }, { action: 'bug.delete', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }], + [MemberController, 'create', { permission: 'member:create' }, { action: 'member.create', entityType: 'member' }], + [MemberController, 'update', { permission: 'member:edit' }, { action: 'member.update', entityType: 'member', entityIdParam: 'id' }], + [MemberController, 'remove', { permission: 'member:delete' }, { action: 'member.delete', entityType: 'member', entityIdParam: 'id' }], + [TaskCategoryController, 'create', { permission: 'task-category:manage' }, { action: 'task_category.create', entityType: 'task_category' }], + [TaskCategoryController, 'update', { permission: 'task-category:manage' }, { action: 'task_category.update', entityType: 'task_category', entityIdParam: 'id' }], + [TaskCategoryController, 'remove', { permission: 'task-category:manage' }, { action: 'task_category.delete', entityType: 'task_category', entityIdParam: 'id' }], + [TaskWorklogController, 'create', { permission: 'version.devtask:manage', versionIdBody: 'versionId' }, { action: 'task_worklog.create', entityType: 'task_worklog', versionIdBody: 'versionId' }], + [TaskWorklogController, 'remove', { permission: 'version.devtask:manage' }, { action: 'task_worklog.delete', entityType: 'task_worklog', entityIdParam: 'id' }], + [OvertimeController, 'create', { permission: 'overtime:create', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'overtime.create', entityType: 'overtime', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }], + [OvertimeController, 'update', { permission: 'overtime:create', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'overtime.update', entityType: 'overtime', entityIdParam: 'id', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }], + [OvertimeController, 'remove', { permission: 'overtime:delete' }, { action: 'overtime.delete', entityType: 'overtime', entityIdParam: 'id' }], + [WorkActivityController, 'create', { permission: 'work-activity:manage', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'work_activity.create', entityType: 'work_activity', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }], + [WorkActivityController, 'remove', { permission: 'work-activity:manage' }, { action: 'work_activity.delete', entityType: 'work_activity', entityIdParam: 'id' }], + ]; + + it.each(cases)('%p.%s declares server permission and audit metadata', (controller, methodName, permission, audit) => { + const handler = controller.prototype[methodName]; + + expect(reflector.get(PERMISSION_METADATA_KEY, handler)).toEqual(permission); + expect(reflector.get(AUDIT_MUTATION_METADATA_KEY, handler)).toEqual(audit); + }); +}); diff --git a/apps/server/src/modules/version-plan/version-plan.controller.ts b/apps/server/src/modules/version-plan/version-plan.controller.ts index e9f68d7..035f82f 100644 --- a/apps/server/src/modules/version-plan/version-plan.controller.ts +++ b/apps/server/src/modules/version-plan/version-plan.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { CreateVersionPlanDto } from './dto/create-version-plan.dto'; import { UpdateVersionPlanDto } from './dto/update-version-plan.dto'; import { VersionPlanService } from './version-plan.service'; @@ -8,6 +9,11 @@ export class VersionPlanController { constructor(private readonly versionPlanService: VersionPlanService) {} @Post() + @ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, { + action: 'version_plan.create', + entityType: 'version_plan', + versionIdParam: 'versionId', + }) create(@Param('versionId') versionId: string, @Body() dto: CreateVersionPlanDto) { return this.versionPlanService.create(versionId, dto); } @@ -18,16 +24,34 @@ export class VersionPlanController { } @Patch(':id') + @ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, { + action: 'version_plan.update', + entityType: 'version_plan', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) { return this.versionPlanService.update(versionId, id, dto); } @Patch(':id/complete') + @ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, { + action: 'version_plan.complete', + entityType: 'version_plan', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) complete(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) { return this.versionPlanService.complete(versionId, id, dto); } @Delete(':id') + @ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, { + action: 'version_plan.delete', + entityType: 'version_plan', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) remove(@Param('versionId') versionId: string, @Param('id') id: string) { return this.versionPlanService.remove(versionId, id); } diff --git a/apps/server/src/modules/version/version.controller.ts b/apps/server/src/modules/version/version.controller.ts index 3c36a18..feead76 100644 --- a/apps/server/src/modules/version/version.controller.ts +++ b/apps/server/src/modules/version/version.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { VersionService } from './version.service'; import { CreateVersionDto } from './dto/create-version.dto'; import { UpdateVersionDto } from './dto/update-version.dto'; @@ -8,6 +9,11 @@ export class VersionController { constructor(private readonly versionService: VersionService) {} @Post('versions') + @ProtectedMutation('version:create', { productIdParam: 'productId' }, { + action: 'version.create', + entityType: 'version', + productIdParam: 'productId', + }) create(@Param('productId') productId: string, @Body() dto: CreateVersionDto) { return this.versionService.create(productId, dto); } @@ -18,6 +24,12 @@ export class VersionController { } @Post('projects/:projectId/versions') + @ProtectedMutation('version:create', { productIdParam: 'productId', projectIdParam: 'projectId' }, { + action: 'version.create', + entityType: 'version', + productIdParam: 'productId', + projectIdParam: 'projectId', + }) createForProject( @Param('productId') productId: string, @Param('projectId') projectId: string, @@ -32,6 +44,13 @@ export class VersionController { } @Patch('versions/:versionId') + @ProtectedMutation('version:edit', { productIdParam: 'productId', versionIdParam: 'versionId' }, { + action: 'version.update', + entityType: 'version', + entityIdParam: 'versionId', + productIdParam: 'productId', + versionIdParam: 'versionId', + }) update( @Param('productId') productId: string, @Param('versionId') versionId: string, @@ -41,6 +60,13 @@ export class VersionController { } @Delete('versions/:versionId') + @ProtectedMutation('version:delete', { productIdParam: 'productId', versionIdParam: 'versionId' }, { + action: 'version.delete', + entityType: 'version', + entityIdParam: 'versionId', + productIdParam: 'productId', + versionIdParam: 'versionId', + }) remove(@Param('productId') productId: string, @Param('versionId') versionId: string) { return this.versionService.remove(productId, versionId); } diff --git a/apps/server/src/modules/work-activity/work-activity.controller.ts b/apps/server/src/modules/work-activity/work-activity.controller.ts index 98d16b2..fa77d69 100644 --- a/apps/server/src/modules/work-activity/work-activity.controller.ts +++ b/apps/server/src/modules/work-activity/work-activity.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { WorkActivityRecordInput, WorkActivityService } from './work-activity.service'; @Controller('work-activities') @@ -11,11 +12,23 @@ export class WorkActivityController { } @Post() + @ProtectedMutation('work-activity:manage', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, { + action: 'work_activity.create', + entityType: 'work_activity', + productIdBody: 'productId', + projectIdBody: 'projectId', + versionIdBody: 'versionId', + }) create(@Body() dto: WorkActivityRecordInput) { return this.workActivityService.record(dto); } @Delete(':id') + @ProtectedMutation('work-activity:manage', {}, { + action: 'work_activity.delete', + entityType: 'work_activity', + entityIdParam: 'id', + }) remove(@Param('id') id: string) { return this.workActivityService.remove(id); } diff --git a/apps/web/lib/api.test.ts b/apps/web/lib/api.test.ts index 603b6d9..9e26ae2 100644 --- a/apps/web/lib/api.test.ts +++ b/apps/web/lib/api.test.ts @@ -47,6 +47,44 @@ test('API requests use the resolved base path', async () => { } }); +test('API requests include current auth user headers when a session exists', async () => { + const originalFetch = globalThis.fetch; + const originalSessionStorage = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + const headers: HeadersInit[] = []; + + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: { + getItem: (key: string) => key === 'ftb_auth_session' + ? JSON.stringify({ id: 'm-8', name: '超级管理员', username: 'admin', roleId: 'role-admin', email: 'admin@example.com' }) + : null, + }, + }); + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + headers.push(init?.headers ?? {}); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + __resetApiAvailabilityForTests(); + await api.post('/products', { name: 'FTB' }); + assert.equal((headers[1] as Record)['x-ftb-user-id'], 'm-8'); + assert.equal((headers[1] as Record)['x-ftb-user-role-id'], 'role-admin'); + assert.equal((headers[1] as Record)['x-ftb-user-name'], encodeURIComponent('超级管理员')); + } finally { + globalThis.fetch = originalFetch; + if (originalSessionStorage) { + Object.defineProperty(globalThis, 'sessionStorage', originalSessionStorage); + } else { + delete (globalThis as any).sessionStorage; + } + } +}); + test('API availability probe retries after a transient failure', async () => { const originalFetch = globalThis.fetch; const apiBase = resolveApiBase(); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index dd2ebf0..0581a2b 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -13,6 +13,8 @@ export function resolveApiBase(env: ApiEnv = process.env) { } const API_BASE = resolveApiBase(); +const AUTH_SESSION_KEY = 'ftb_auth_session'; +const AUTH_PERSIST_KEY = 'ftb_auth_persist'; function isLocalOnlyApiUrl(value: string) { try { @@ -67,7 +69,7 @@ async function request(path: string, options?: RequestInit): Promise { const available = await checkApi(); if (!available) throw new Error('API 不可用'); const res = await fetch(`${API_BASE}${path}`, { - headers: { 'Content-Type': 'application/json', ...options?.headers }, + headers: { 'Content-Type': 'application/json', ...resolveAuthHeaders(), ...options?.headers }, ...options, }).catch(() => null); if (!res) { @@ -97,7 +99,7 @@ export const api = { try { const res = await fetch(`${API_BASE}${path}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...resolveAuthHeaders() }, body: JSON.stringify(data), signal: controller.signal, }); @@ -116,3 +118,36 @@ export function __resetApiAvailabilityForTests() { apiAvailable = null; probePromise = null; } + +function resolveAuthHeaders(): Record { + const user = readStoredAuthUser(); + if (!user?.id) return {}; + return { + 'x-ftb-user-id': user.id, + ...(user.roleId ? { 'x-ftb-user-role-id': user.roleId } : {}), + ...(user.name ? { 'x-ftb-user-name': encodeURIComponent(user.name) } : {}), + ...(user.username ? { 'x-ftb-user-username': user.username } : {}), + ...(user.email ? { 'x-ftb-user-email': user.email } : {}), + }; +} + +function readStoredAuthUser(): { id?: string; roleId?: string; name?: string; username?: string; email?: string } | null { + if (typeof sessionStorage === 'undefined') return null; + const raw = safeStorageGet(sessionStorage, AUTH_SESSION_KEY) + ?? (typeof localStorage === 'undefined' ? null : safeStorageGet(localStorage, AUTH_PERSIST_KEY)); + if (!raw) return null; + try { + const value = JSON.parse(raw); + return value && typeof value === 'object' ? value : null; + } catch { + return null; + } +} + +function safeStorageGet(storage: Pick, key: string): string | null { + try { + return storage.getItem(key); + } catch { + return null; + } +}