merge: 集成V2.7 企业协作与管理治理

# Conflicts:
#	apps/server/src/app.module.ts
#	apps/web/components/layout/Sidebar.tsx
#	apps/web/lib/permissions.ts
#	docs/architecture.md
#	docs/decisions.md
#	docs/roadmap.md
This commit is contained in:
2026-07-08 18:10:08 +08:00
60 changed files with 3219 additions and 19 deletions

View File

@@ -0,0 +1,51 @@
import { AuditService } from './audit.service';
describe('AuditService V2.7 adapter contract', () => {
it('writes an append-only audit event with actor and resource scope', async () => {
const prisma = {
auditLog: {
create: jest.fn().mockResolvedValue({ id: 'audit-1' }),
},
};
const service = new AuditService(prisma as any);
await service.record({
actorId: 'm-admin',
action: 'project_member.role_changed',
resourceType: 'project_member',
resourceId: 'pm-1',
projectId: 'project-1',
before: { role: 'member' },
after: { role: 'admin' },
});
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
actorId: 'm-admin',
action: 'project_member.role_changed',
resourceType: 'project_member',
resourceId: 'pm-1',
projectId: 'project-1',
before: { role: 'member' },
after: { role: 'admin' },
}),
});
});
it('does not fail the business action when audit storage is unavailable', async () => {
const prisma = {
auditLog: {
create: jest.fn().mockRejectedValue(new Error('database unavailable')),
},
};
const service = new AuditService(prisma as any);
(service as any).logger.warn = jest.fn();
await expect(service.record({
actorId: 'm-admin',
action: 'comment.created',
resourceType: 'comment',
resourceId: 'comment-1',
})).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface AuditEventInput {
actorId?: string | null;
action: string;
resourceType: string;
resourceId: string;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
before?: unknown;
after?: unknown;
metadata?: unknown;
}
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);
constructor(private readonly prisma: PrismaService) {}
async record(input: AuditEventInput): Promise<void> {
try {
await this.prisma.auditLog.create({
data: {
actorId: input.actorId ?? null,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
before: input.before ?? {},
after: input.after ?? {},
metadata: input.metadata ?? {},
},
});
} catch (error: any) {
this.logger.warn(`Audit write failed for ${input.action}: ${error?.message ?? error}`);
}
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AuditService } from './audit/audit.service';
import { RbacService } from './rbac/rbac.service';
@Module({
providers: [AuditService, RbacService],
exports: [AuditService, RbacService],
})
export class CommonDomainModule {}

View File

@@ -0,0 +1,93 @@
import { ForbiddenException } from '@nestjs/common';
import { RbacService } from './rbac.service';
describe('RbacService V2.7 adapter contract', () => {
const makeService = () => {
const prisma = {
projectMember: {
findUnique: jest.fn(),
},
};
return { prisma, service: new RbacService(prisma as any) };
};
it('allows a system admin actor without querying project membership', async () => {
const { prisma, service } = makeService();
await expect(service.assertProjectRole({
actorId: 'm-admin',
projectId: 'project-1',
allowedRoles: ['owner'],
permissions: ['*'],
})).resolves.toEqual({
actorId: 'm-admin',
projectId: 'project-1',
role: 'owner',
via: 'system',
});
expect(prisma.projectMember.findUnique).not.toHaveBeenCalled();
});
it('allows actors with an explicit global permission', async () => {
const { service } = makeService();
await expect(service.assertGlobalPermission({
actorId: 'm-pm',
permissions: ['management:view'],
requiredPermissions: ['management:view'],
})).resolves.toEqual({
actorId: 'm-pm',
via: 'permission',
});
});
it('rejects actors without the required global permission', async () => {
const { service } = makeService();
await expect(service.assertGlobalPermission({
actorId: 'm-dev',
permissions: ['project:view'],
requiredPermissions: ['governance:manage'],
})).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows project owners to perform admin-scoped actions', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-1',
projectId: 'project-1',
userId: 'm-owner',
role: 'owner',
});
await expect(service.assertProjectRole({
actorId: 'm-owner',
projectId: 'project-1',
allowedRoles: ['admin'],
permissions: [],
})).resolves.toEqual({
actorId: 'm-owner',
projectId: 'project-1',
role: 'owner',
via: 'project_member',
});
});
it('rejects actors below the required project role', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-1',
projectId: 'project-1',
userId: 'm-viewer',
role: 'viewer',
});
await expect(service.assertProjectRole({
actorId: 'm-viewer',
projectId: 'project-1',
allowedRoles: ['member'],
permissions: [],
})).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -0,0 +1,94 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export type ProjectGovernanceRole = 'owner' | 'admin' | 'member' | 'viewer';
export interface ProjectRoleAssertion {
actorId?: string;
projectId?: string | null;
allowedRoles: ProjectGovernanceRole[];
permissions?: string[];
}
export interface GlobalPermissionAssertion {
actorId?: string;
permissions?: string[];
requiredPermissions: string[];
}
export interface ProjectRoleDecision {
actorId: string;
projectId: string;
role: ProjectGovernanceRole;
via: 'system' | 'project_member';
}
export interface GlobalPermissionDecision {
actorId: string;
via: 'system' | 'permission';
}
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
owner: 4,
admin: 3,
member: 2,
viewer: 1,
};
@Injectable()
export class RbacService {
constructor(private readonly prisma: PrismaService) {}
async assertGlobalPermission(input: GlobalPermissionAssertion): Promise<GlobalPermissionDecision> {
const actorId = input.actorId?.trim();
if (!actorId) {
throw new ForbiddenException('Missing actor scope');
}
if (input.permissions?.includes('*')) {
return { actorId, via: 'system' };
}
if (hasAnyPermission(input.permissions ?? [], input.requiredPermissions)) {
return { actorId, via: 'permission' };
}
throw new ForbiddenException('Insufficient global permission');
}
async assertProjectRole(input: ProjectRoleAssertion): Promise<ProjectRoleDecision> {
const actorId = input.actorId?.trim();
const projectId = input.projectId?.trim();
if (!actorId || !projectId) {
throw new ForbiddenException('Missing actor or project scope');
}
if (input.permissions?.includes('*')) {
return { actorId, projectId, role: 'owner', via: 'system' };
}
const membership = await this.prisma.projectMember.findUnique({
where: { projectId_userId: { projectId, userId: actorId } },
});
const role = normalizeProjectRole(membership?.role);
if (!role || !hasRequiredRole(role, input.allowedRoles)) {
throw new ForbiddenException('Insufficient project role');
}
return { actorId, projectId, role, via: 'project_member' };
}
}
export function normalizeProjectRole(role: string | null | undefined): ProjectGovernanceRole | null {
if (role === 'owner' || role === 'admin' || role === 'member' || role === 'viewer') return role;
return null;
}
export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: ProjectGovernanceRole[]): boolean {
if (allowedRoles.length === 0) return false;
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
return ROLE_RANK[role] >= minimumRank;
}
export function hasAnyPermission(permissions: string[], requiredPermissions: string[]): boolean {
if (requiredPermissions.length === 0) return false;
const granted = new Set(permissions);
return requiredPermissions.some((permission) => granted.has(permission));
}