feat(v2.7): 建立协作治理基础合同
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
ALTER TABLE "comments" DROP CONSTRAINT IF EXISTS "comments_task_id_fkey";
|
||||||
|
DROP TABLE IF EXISTS "comments";
|
||||||
|
|
||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"entity_type" TEXT NOT NULL,
|
||||||
|
"entity_id" TEXT NOT NULL,
|
||||||
|
"entity_version_id" TEXT,
|
||||||
|
"product_id" TEXT,
|
||||||
|
"project_id" TEXT,
|
||||||
|
"version_id" TEXT,
|
||||||
|
"author_id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"mentioned_member_ids" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"deleted_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "comments_entity_created_idx" ON "comments"("entity_type", "entity_id", "created_at");
|
||||||
|
CREATE INDEX "comments_author_created_idx" ON "comments"("author_id", "created_at");
|
||||||
|
|
||||||
|
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
CREATE INDEX IF NOT EXISTS "project_members_user_role_idx" ON "project_members"("user_id", "role");
|
||||||
|
|
||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"recipient_id" TEXT NOT NULL,
|
||||||
|
"actor_id" TEXT,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL DEFAULT '',
|
||||||
|
"resource_type" TEXT NOT NULL,
|
||||||
|
"resource_id" TEXT NOT NULL,
|
||||||
|
"resource_version_id" TEXT,
|
||||||
|
"product_id" TEXT,
|
||||||
|
"project_id" TEXT,
|
||||||
|
"version_id" TEXT,
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"read_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "notifications_recipient_read_created_idx" ON "notifications"("recipient_id", "read_at", "created_at");
|
||||||
|
CREATE INDEX "notifications_resource_idx" ON "notifications"("resource_type", "resource_id");
|
||||||
|
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"actor_id" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"resource_type" TEXT NOT NULL,
|
||||||
|
"resource_id" TEXT NOT NULL,
|
||||||
|
"product_id" TEXT,
|
||||||
|
"project_id" TEXT,
|
||||||
|
"version_id" TEXT,
|
||||||
|
"before" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"after" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "audit_logs_resource_created_idx" ON "audit_logs"("resource_type", "resource_id", "created_at");
|
||||||
|
CREATE INDEX "audit_logs_actor_created_idx" ON "audit_logs"("actor_id", "created_at");
|
||||||
|
|
||||||
|
CREATE TABLE "governance_dictionaries" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL DEFAULT 'global',
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"code" TEXT,
|
||||||
|
"group" TEXT,
|
||||||
|
"value" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"deleted_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "governance_dictionaries_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE ("scope", "kind", "name")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "governance_dictionaries_kind_deleted_idx" ON "governance_dictionaries"("kind", "deleted_at");
|
||||||
@@ -138,7 +138,6 @@ model Task {
|
|||||||
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
|
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
|
||||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||||
watchers TaskWatcher[]
|
watchers TaskWatcher[]
|
||||||
comments Comment[]
|
|
||||||
|
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
}
|
}
|
||||||
@@ -156,30 +155,100 @@ model TaskWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String @map("task_id")
|
entityType String @map("entity_type")
|
||||||
authorId String @map("author_id")
|
entityId String @map("entity_id")
|
||||||
content String
|
entityVersionId String? @map("entity_version_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
productId String? @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
task Task @relation(fields: [taskId], references: [id])
|
versionId String? @map("version_id")
|
||||||
|
authorId String @map("author_id")
|
||||||
|
content String
|
||||||
|
mentionedMemberIds Json @default("[]") @map("mentioned_member_ids")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([entityType, entityId, createdAt], name: "comments_entity_created_idx")
|
||||||
|
@@index([authorId, createdAt], name: "comments_author_created_idx")
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProjectMember {
|
model ProjectMember {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String @map("project_id")
|
projectId String @map("project_id")
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
role String @default("member")
|
role String @default("member")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@unique([projectId, userId])
|
@@unique([projectId, userId])
|
||||||
|
@@index([userId, role], name: "project_members_user_role_idx")
|
||||||
@@map("project_members")
|
@@map("project_members")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
recipientId String @map("recipient_id")
|
||||||
|
actorId String? @map("actor_id")
|
||||||
|
type String
|
||||||
|
title String
|
||||||
|
body String @default("")
|
||||||
|
resourceType String @map("resource_type")
|
||||||
|
resourceId String @map("resource_id")
|
||||||
|
resourceVersionId String? @map("resource_version_id")
|
||||||
|
productId String? @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
versionId String? @map("version_id")
|
||||||
|
metadata Json @default("{}")
|
||||||
|
readAt DateTime? @map("read_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([recipientId, readAt, createdAt], name: "notifications_recipient_read_created_idx")
|
||||||
|
@@index([resourceType, resourceId], name: "notifications_resource_idx")
|
||||||
|
@@map("notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
actorId String? @map("actor_id")
|
||||||
|
action String
|
||||||
|
resourceType String @map("resource_type")
|
||||||
|
resourceId String @map("resource_id")
|
||||||
|
productId String? @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
versionId String? @map("version_id")
|
||||||
|
before Json @default("{}")
|
||||||
|
after Json @default("{}")
|
||||||
|
metadata Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([resourceType, resourceId, createdAt], name: "audit_logs_resource_created_idx")
|
||||||
|
@@index([actorId, createdAt], name: "audit_logs_actor_created_idx")
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GovernanceDictionary {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scope String @default("global")
|
||||||
|
kind String
|
||||||
|
name String
|
||||||
|
code String?
|
||||||
|
group String?
|
||||||
|
value Json @default("{}")
|
||||||
|
isSystem Boolean @default(false) @map("is_system")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([scope, kind, name])
|
||||||
|
@@index([kind, deletedAt], name: "governance_dictionaries_kind_deleted_idx")
|
||||||
|
@@map("governance_dictionaries")
|
||||||
|
}
|
||||||
|
|
||||||
model TaskCategory {
|
model TaskCategory {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
|||||||
50
apps/server/prisma/v27-enterprise-schema.spec.ts
Normal file
50
apps/server/prisma/v27-enterprise-schema.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const migrationPath = join(
|
||||||
|
process.cwd(),
|
||||||
|
'prisma',
|
||||||
|
'migrations',
|
||||||
|
'20260708000000_v27_enterprise_collab_governance',
|
||||||
|
'migration.sql',
|
||||||
|
);
|
||||||
|
|
||||||
|
function readMigrationSql() {
|
||||||
|
if (!existsSync(migrationPath)) {
|
||||||
|
throw new Error(`Missing V2.7 migration: ${migrationPath}`);
|
||||||
|
}
|
||||||
|
return readFileSync(migrationPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('V2.7 enterprise collaboration schema migration', () => {
|
||||||
|
it('creates notification records for assignment, mention, risk alert, and overdue events', () => {
|
||||||
|
const sql = readMigrationSql();
|
||||||
|
|
||||||
|
expect(sql).toMatch(/CREATE TABLE "notifications"/);
|
||||||
|
expect(sql).toMatch(/"type" TEXT NOT NULL/);
|
||||||
|
expect(sql).toMatch(/"recipient_id" TEXT NOT NULL/);
|
||||||
|
expect(sql).toMatch(/"read_at" TIMESTAMP\(3\)/);
|
||||||
|
expect(sql).toMatch(/CREATE INDEX "notifications_recipient_read_created_idx"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates polymorphic comments with mention metadata', () => {
|
||||||
|
const sql = readMigrationSql();
|
||||||
|
|
||||||
|
expect(sql).toMatch(/CREATE TABLE "comments"/);
|
||||||
|
expect(sql).toMatch(/"entity_type" TEXT NOT NULL/);
|
||||||
|
expect(sql).toMatch(/"entity_id" TEXT NOT NULL/);
|
||||||
|
expect(sql).toMatch(/"mentioned_member_ids" JSONB NOT NULL DEFAULT '\[\]'/);
|
||||||
|
expect(sql).toMatch(/CREATE INDEX "comments_entity_created_idx"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds audit logs and governance dictionaries with soft-delete support', () => {
|
||||||
|
const sql = readMigrationSql();
|
||||||
|
|
||||||
|
expect(sql).toMatch(/CREATE TABLE "audit_logs"/);
|
||||||
|
expect(sql).toMatch(/"before" JSONB NOT NULL DEFAULT '\{\}'/);
|
||||||
|
expect(sql).toMatch(/"after" JSONB NOT NULL DEFAULT '\{\}'/);
|
||||||
|
expect(sql).toMatch(/CREATE TABLE "governance_dictionaries"/);
|
||||||
|
expect(sql).toMatch(/"deleted_at" TIMESTAMP\(3\)/);
|
||||||
|
expect(sql).toMatch(/CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE/);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
51
apps/server/src/common/audit/audit.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
apps/server/src/common/audit/audit.service.ts
Normal file
43
apps/server/src/common/audit/audit.service.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
70
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
apps/server/src/common/rbac/rbac.service.ts
Normal file
63
apps/server/src/common/rbac/rbac.service.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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 ProjectRoleDecision {
|
||||||
|
actorId: string;
|
||||||
|
projectId: string;
|
||||||
|
role: ProjectGovernanceRole;
|
||||||
|
via: 'system' | 'project_member';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
|
||||||
|
owner: 4,
|
||||||
|
admin: 3,
|
||||||
|
member: 2,
|
||||||
|
viewer: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RbacService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
- "apps/*"
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
|
allowBuilds:
|
||||||
|
'@nestjs/core': true
|
||||||
|
'@prisma/client': true
|
||||||
|
'@prisma/engines': true
|
||||||
|
prisma: true
|
||||||
|
|||||||
Reference in New Issue
Block a user