feat(v2.7): 收口治理权限适配器
This commit is contained in:
@@ -29,6 +29,29 @@ describe('RbacService V2.7 adapter contract', () => {
|
|||||||
expect(prisma.projectMember.findUnique).not.toHaveBeenCalled();
|
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 () => {
|
it('allows project owners to perform admin-scoped actions', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
prisma.projectMember.findUnique.mockResolvedValue({
|
prisma.projectMember.findUnique.mockResolvedValue({
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export interface ProjectRoleAssertion {
|
|||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GlobalPermissionAssertion {
|
||||||
|
actorId?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
requiredPermissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectRoleDecision {
|
export interface ProjectRoleDecision {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -17,6 +23,11 @@ export interface ProjectRoleDecision {
|
|||||||
via: 'system' | 'project_member';
|
via: 'system' | 'project_member';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GlobalPermissionDecision {
|
||||||
|
actorId: string;
|
||||||
|
via: 'system' | 'permission';
|
||||||
|
}
|
||||||
|
|
||||||
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
|
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
|
||||||
owner: 4,
|
owner: 4,
|
||||||
admin: 3,
|
admin: 3,
|
||||||
@@ -28,6 +39,20 @@ const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
|
|||||||
export class RbacService {
|
export class RbacService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
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> {
|
async assertProjectRole(input: ProjectRoleAssertion): Promise<ProjectRoleDecision> {
|
||||||
const actorId = input.actorId?.trim();
|
const actorId = input.actorId?.trim();
|
||||||
const projectId = input.projectId?.trim();
|
const projectId = input.projectId?.trim();
|
||||||
@@ -61,3 +86,9 @@ export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: Proje
|
|||||||
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
|
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
|
||||||
return ROLE_RANK[role] >= minimumRank;
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,17 +23,32 @@ export class GovernanceDictionaryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RemoveGovernanceDictionaryDto {
|
export class RemoveGovernanceDictionaryDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
actorId!: string;
|
actorId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImportGovernanceDictionariesDto {
|
export class ImportGovernanceDictionariesDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
actorId!: string;
|
actorId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => GovernanceDictionaryDto)
|
@Type(() => GovernanceDictionaryDto)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class GovernanceController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: RemoveGovernanceDictionaryDto,
|
@Body() dto: RemoveGovernanceDictionaryDto,
|
||||||
) {
|
) {
|
||||||
return this.governanceService.remove({ actorId: dto.actorId, kind, id });
|
return this.governanceService.remove({ actorId: dto.actorId, permissions: dto.permissions, kind, id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
@@ -41,6 +41,6 @@ export class GovernanceController {
|
|||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
importAll(@Body() dto: ImportGovernanceDictionariesDto) {
|
importAll(@Body() dto: ImportGovernanceDictionariesDto) {
|
||||||
return this.governanceService.importAll(dto.actorId, dto.items);
|
return this.governanceService.importAll(dto.actorId, dto.items, dto.permissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { AuditService } from '../../common/audit/audit.service';
|
import { AuditService } from '../../common/audit/audit.service';
|
||||||
|
import { RbacService } from '../../common/rbac/rbac.service';
|
||||||
import { GovernanceService } from './governance.service';
|
import { GovernanceService } from './governance.service';
|
||||||
|
|
||||||
describe('GovernanceService', () => {
|
describe('GovernanceService', () => {
|
||||||
@@ -21,20 +22,43 @@ describe('GovernanceService', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const audit = { record: jest.fn() } as unknown as AuditService;
|
const audit = { record: jest.fn() } as unknown as AuditService;
|
||||||
return { prisma, audit, service: new GovernanceService(prisma as any, audit) };
|
const rbac = {
|
||||||
|
assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-admin', via: 'system' }),
|
||||||
|
} as unknown as RbacService;
|
||||||
|
return { prisma, audit, rbac, service: new GovernanceService(prisma as any, audit, rbac) };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('requires governance manage permission through the RBAC adapter', async () => {
|
||||||
|
const { prisma, rbac, service } = makeService();
|
||||||
|
(rbac.assertGlobalPermission as jest.Mock).mockRejectedValue(new Error('forbidden'));
|
||||||
|
|
||||||
|
await expect(service.create({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
permissions: ['project:view'],
|
||||||
|
kind: 'requirement_type',
|
||||||
|
name: '新功能',
|
||||||
|
})).rejects.toThrow('forbidden');
|
||||||
|
|
||||||
|
expect(prisma.governanceDictionary.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => {
|
it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, rbac, service } = makeService();
|
||||||
prisma.devTask.count.mockResolvedValue(1);
|
prisma.devTask.count.mockResolvedValue(1);
|
||||||
prisma.testCase.count.mockResolvedValue(0);
|
prisma.testCase.count.mockResolvedValue(0);
|
||||||
|
|
||||||
await expect(service.remove({
|
await expect(service.remove({
|
||||||
actorId: 'm-admin',
|
actorId: 'm-admin',
|
||||||
|
permissions: ['governance:manage'],
|
||||||
kind: 'task_category',
|
kind: 'task_category',
|
||||||
id: 'cat-1',
|
id: 'cat-1',
|
||||||
})).rejects.toBeInstanceOf(BadRequestException);
|
})).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({
|
||||||
|
actorId: 'm-admin',
|
||||||
|
permissions: ['governance:manage'],
|
||||||
|
requiredPermissions: ['governance:manage'],
|
||||||
|
});
|
||||||
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
|
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,7 +66,7 @@ describe('GovernanceService', () => {
|
|||||||
const { prisma, audit, service } = makeService();
|
const { prisma, audit, service } = makeService();
|
||||||
prisma.governanceDictionary.update.mockResolvedValue({ id: 'dict-1', kind: 'requirement_type', deletedAt: new Date('2026-07-08T00:00:00.000Z') });
|
prisma.governanceDictionary.update.mockResolvedValue({ id: 'dict-1', kind: 'requirement_type', deletedAt: new Date('2026-07-08T00:00:00.000Z') });
|
||||||
|
|
||||||
await service.remove({ actorId: 'm-admin', kind: 'requirement_type', id: 'dict-1' });
|
await service.remove({ actorId: 'm-admin', permissions: ['governance:manage'], kind: 'requirement_type', id: 'dict-1' });
|
||||||
|
|
||||||
expect(prisma.governanceDictionary.update).toHaveBeenCalledWith({
|
expect(prisma.governanceDictionary.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'dict-1' },
|
where: { id: 'dict-1' },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AuditService } from '../../common/audit/audit.service';
|
import { AuditService } from '../../common/audit/audit.service';
|
||||||
|
import { RbacService } from '../../common/rbac/rbac.service';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
export const GOVERNANCE_DICTIONARY_KINDS = ['task_category', 'requirement_type', 'requirement_platform', 'requirement_source'] as const;
|
export const GOVERNANCE_DICTIONARY_KINDS = ['task_category', 'requirement_type', 'requirement_platform', 'requirement_source'] as const;
|
||||||
@@ -14,12 +15,14 @@ export interface GovernanceDictionaryInput {
|
|||||||
group?: string | null;
|
group?: string | null;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
value?: unknown;
|
value?: unknown;
|
||||||
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GovernanceRemoveInput {
|
export interface GovernanceRemoveInput {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
kind: GovernanceDictionaryKind;
|
kind: GovernanceDictionaryKind;
|
||||||
id: string;
|
id: string;
|
||||||
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -27,6 +30,7 @@ export class GovernanceService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
|
private readonly rbacService: RbacService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
list(kind: GovernanceDictionaryKind) {
|
list(kind: GovernanceDictionaryKind) {
|
||||||
@@ -43,6 +47,7 @@ export class GovernanceService {
|
|||||||
async create(input: GovernanceDictionaryInput) {
|
async create(input: GovernanceDictionaryInput) {
|
||||||
assertDictionaryKind(input.kind);
|
assertDictionaryKind(input.kind);
|
||||||
const actorId = requireText(input.actorId, 'actorId');
|
const actorId = requireText(input.actorId, 'actorId');
|
||||||
|
await this.assertManagePermission(actorId, input.permissions);
|
||||||
const created = input.kind === 'task_category'
|
const created = input.kind === 'task_category'
|
||||||
? await this.prisma.taskCategory.create({
|
? await this.prisma.taskCategory.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -75,6 +80,7 @@ export class GovernanceService {
|
|||||||
async update(input: GovernanceDictionaryInput & { id: string }) {
|
async update(input: GovernanceDictionaryInput & { id: string }) {
|
||||||
assertDictionaryKind(input.kind);
|
assertDictionaryKind(input.kind);
|
||||||
const actorId = requireText(input.actorId, 'actorId');
|
const actorId = requireText(input.actorId, 'actorId');
|
||||||
|
await this.assertManagePermission(actorId, input.permissions);
|
||||||
const id = requireText(input.id, 'id');
|
const id = requireText(input.id, 'id');
|
||||||
const updated = input.kind === 'task_category'
|
const updated = input.kind === 'task_category'
|
||||||
? await this.prisma.taskCategory.update({
|
? await this.prisma.taskCategory.update({
|
||||||
@@ -108,6 +114,7 @@ export class GovernanceService {
|
|||||||
async remove(input: GovernanceRemoveInput) {
|
async remove(input: GovernanceRemoveInput) {
|
||||||
assertDictionaryKind(input.kind);
|
assertDictionaryKind(input.kind);
|
||||||
const actorId = requireText(input.actorId, 'actorId');
|
const actorId = requireText(input.actorId, 'actorId');
|
||||||
|
await this.assertManagePermission(actorId, input.permissions);
|
||||||
const id = requireText(input.id, 'id');
|
const id = requireText(input.id, 'id');
|
||||||
if (input.kind === 'task_category') {
|
if (input.kind === 'task_category') {
|
||||||
const [devTaskCount, testCaseCount] = await Promise.all([
|
const [devTaskCount, testCaseCount] = await Promise.all([
|
||||||
@@ -150,12 +157,13 @@ export class GovernanceService {
|
|||||||
return { taskCategories, dictionaries };
|
return { taskCategories, dictionaries };
|
||||||
}
|
}
|
||||||
|
|
||||||
async importAll(actorId: string, items: GovernanceDictionaryInput[]) {
|
async importAll(actorId: string, items: GovernanceDictionaryInput[], permissions: string[] = []) {
|
||||||
|
await this.assertManagePermission(actorId, permissions);
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
assertDictionaryKind(item.kind);
|
assertDictionaryKind(item.kind);
|
||||||
if (item.kind === 'task_category') {
|
if (item.kind === 'task_category') {
|
||||||
results.push(await this.create({ ...item, actorId }));
|
results.push(await this.create({ ...item, actorId, permissions }));
|
||||||
} else {
|
} else {
|
||||||
const saved = await this.prisma.governanceDictionary.upsert({
|
const saved = await this.prisma.governanceDictionary.upsert({
|
||||||
where: {
|
where: {
|
||||||
@@ -196,6 +204,14 @@ export class GovernanceService {
|
|||||||
private resourceType(kind: GovernanceDictionaryKind) {
|
private resourceType(kind: GovernanceDictionaryKind) {
|
||||||
return kind === 'task_category' ? 'task_category' : 'governance_dictionary';
|
return kind === 'task_category' ? 'task_category' : 'governance_dictionary';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertManagePermission(actorId: string, permissions: string[] = []) {
|
||||||
|
return this.rbacService.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['governance:manage'],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
|
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||||
import { ManagementController } from './management.controller';
|
import { ManagementController } from './management.controller';
|
||||||
import { ManagementService } from './management.service';
|
import { ManagementService } from './management.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [CommonDomainModule],
|
||||||
controllers: [ManagementController],
|
controllers: [ManagementController],
|
||||||
providers: [ManagementService],
|
providers: [ManagementService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,14 +12,24 @@ describe('ManagementService', () => {
|
|||||||
bug: { findMany: jest.fn() },
|
bug: { findMany: jest.fn() },
|
||||||
xiaobaoRiskSummary: { findMany: jest.fn() },
|
xiaobaoRiskSummary: { findMany: jest.fn() },
|
||||||
};
|
};
|
||||||
return { prisma, service: new ManagementService(prisma as any) };
|
const rbac = {
|
||||||
|
assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-manager', via: 'permission' }),
|
||||||
|
};
|
||||||
|
return { prisma, rbac, service: new ManagementService(prisma as any, rbac as any) };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('requires management view permission through the RBAC adapter', async () => {
|
||||||
|
const { rbac, service } = makeService();
|
||||||
|
rbac.assertGlobalPermission.mockRejectedValue(new Error('forbidden'));
|
||||||
|
|
||||||
|
await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).rejects.toThrow('forbidden');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns an empty dashboard when actor has no managed projects', async () => {
|
it('returns an empty dashboard when actor has no managed projects', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, rbac, service } = makeService();
|
||||||
prisma.projectMember.findMany.mockResolvedValue([]);
|
prisma.projectMember.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).resolves.toEqual({
|
await expect(service.getOverview({ actorId: 'm-dev', permissions: ['management:view'] })).resolves.toEqual({
|
||||||
activeVersionCount: 0,
|
activeVersionCount: 0,
|
||||||
overdueItemCount: 0,
|
overdueItemCount: 0,
|
||||||
blockedItemCount: 0,
|
blockedItemCount: 0,
|
||||||
@@ -30,6 +40,11 @@ describe('ManagementService', () => {
|
|||||||
blockedItems: [],
|
blockedItems: [],
|
||||||
highRiskVersions: [],
|
highRiskVersions: [],
|
||||||
});
|
});
|
||||||
|
expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
permissions: ['management:view'],
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { RbacService } from '../../common/rbac/rbac.service';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
export interface ManagementOverviewQuery {
|
export interface ManagementOverviewQuery {
|
||||||
@@ -22,13 +23,21 @@ type WorkItem = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ManagementService {
|
export class ManagementService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly rbacService: RbacService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getOverview(query: ManagementOverviewQuery) {
|
async getOverview(query: ManagementOverviewQuery) {
|
||||||
const actorId = query.actorId?.trim();
|
const actorId = query.actorId?.trim();
|
||||||
if (!actorId) throw new BadRequestException('actorId is required');
|
if (!actorId) throw new BadRequestException('actorId is required');
|
||||||
|
const permission = await this.rbacService.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions: query.permissions ?? [],
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
const now = query.now ?? new Date();
|
const now = query.now ?? new Date();
|
||||||
const allProjects = query.permissions?.includes('*') === true;
|
const allProjects = permission.via === 'system';
|
||||||
const projectIds = allProjects ? undefined : await this.getManagedProjectIds(actorId);
|
const projectIds = allProjects ? undefined : await this.getManagedProjectIds(actorId);
|
||||||
if (!allProjects && projectIds?.length === 0) return emptyOverview();
|
if (!allProjects && projectIds?.length === 0) return emptyOverview();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Download, Plus, RefreshCcw, Trash2, Upload } from 'lucide-react';
|
|||||||
import { RouteGuard } from '@/components/auth/Guard';
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
|
||||||
type GovernanceKind = 'task_category' | 'requirement_type' | 'requirement_platform' | 'requirement_source';
|
type GovernanceKind = 'task_category' | 'requirement_type' | 'requirement_platform' | 'requirement_source';
|
||||||
|
|
||||||
@@ -26,12 +27,14 @@ const KIND_LABEL: Record<GovernanceKind, string> = {
|
|||||||
|
|
||||||
function GovernancePageInner() {
|
function GovernancePageInner() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
|
||||||
const [kind, setKind] = useState<GovernanceKind>('task_category');
|
const [kind, setKind] = useState<GovernanceKind>('task_category');
|
||||||
const [items, setItems] = useState<GovernanceItem[]>([]);
|
const [items, setItems] = useState<GovernanceItem[]>([]);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [group, setGroup] = useState('other');
|
const [group, setGroup] = useState('other');
|
||||||
const [exportText, setExportText] = useState('');
|
const [exportText, setExportText] = useState('');
|
||||||
const actorId = user?.id ?? '';
|
const actorId = user?.id ?? '';
|
||||||
|
const permissions = role?.permissions ?? [];
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
const rows = await api.get<GovernanceItem[]>(`/governance/dictionaries?kind=${kind}`);
|
const rows = await api.get<GovernanceItem[]>(`/governance/dictionaries?kind=${kind}`);
|
||||||
@@ -44,14 +47,14 @@ function GovernancePageInner() {
|
|||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
if (!actorId || !name.trim()) return;
|
if (!actorId || !name.trim()) return;
|
||||||
await api.post('/governance/dictionaries', { actorId, kind, name: name.trim(), group });
|
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group });
|
||||||
setName('');
|
setName('');
|
||||||
await reload();
|
await reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (item: GovernanceItem) => {
|
const remove = async (item: GovernanceItem) => {
|
||||||
if (!actorId) return;
|
if (!actorId) return;
|
||||||
await api.deleteWithBody(`/governance/dictionaries/${kind}/${item.id}`, { actorId });
|
await api.deleteWithBody(`/governance/dictionaries/${kind}/${item.id}`, { actorId, permissions });
|
||||||
await reload();
|
await reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@ function GovernancePageInner() {
|
|||||||
const sourceItems = Array.isArray(parsed.items)
|
const sourceItems = Array.isArray(parsed.items)
|
||||||
? parsed.items
|
? parsed.items
|
||||||
: [...(parsed.dictionaries ?? []), ...(parsed.taskCategories ?? []).map((item: GovernanceItem) => ({ ...item, kind: 'task_category' }))];
|
: [...(parsed.dictionaries ?? []), ...(parsed.taskCategories ?? []).map((item: GovernanceItem) => ({ ...item, kind: 'task_category' }))];
|
||||||
await api.post('/governance/import', { actorId, items: sourceItems });
|
await api.post('/governance/import', { actorId, permissions, items: sourceItems });
|
||||||
await reload();
|
await reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -307,3 +307,22 @@ Current source-of-truth boundary:
|
|||||||
- `products-overview` remains the primary document for the product/project/version tree until Project and Version write APIs replace it.
|
- `products-overview` remains the primary document for the product/project/version tree until Project and Version write APIs replace it.
|
||||||
- V2.2 read APIs and V2.3 relation sync are compatibility infrastructure, not proof that every relation model already has a public CRUD API.
|
- V2.2 read APIs and V2.3 relation sync are compatibility infrastructure, not proof that every relation model already has a public CRUD API.
|
||||||
- `packages/shared` still contains early Requirement/Task status enums. Before switching frontend writes to domain APIs, align shared enums with the current workflow statuses in this document.
|
- `packages/shared` still contains early Requirement/Task status enums. Before switching frontend writes to domain APIs, align shared enums with the current workflow statuses in this document.
|
||||||
|
|
||||||
|
## V2.7 Enterprise Collaboration And Governance Layer (2026-07-08)
|
||||||
|
|
||||||
|
V2.7 adds enterprise collaboration capabilities on top of the relational source-of-truth direction. New collaboration data does not add AppData keys:
|
||||||
|
|
||||||
|
- `notifications`: per-recipient notification records with stable event types `assignment / mention / risk_alert / overdue_item`.
|
||||||
|
- `comments`: polymorphic comments for `dev_task / test_case / bug / requirement / version_plan`, with mention metadata and soft deletion.
|
||||||
|
- `project_members`: project-level Owner/Admin/Member/Viewer governance, now exposed through server-enforced APIs.
|
||||||
|
- `audit_logs`: append-only governance and collaboration audit events.
|
||||||
|
- `governance_dictionaries`: centralized requirement type/platform/source dictionaries; task categories continue to use `task_categories`.
|
||||||
|
|
||||||
|
Because the full V2.5 RBAC/audit contract is not fully materialized as a standalone backend framework yet, V2.7 uses stable server adapters:
|
||||||
|
|
||||||
|
- `RbacService`: project role and global permission assertion adapter. Feature modules call this instead of hard-coding permission checks.
|
||||||
|
- `AuditService`: append-only audit adapter. Feature modules call this instead of writing ad-hoc audit records.
|
||||||
|
|
||||||
|
When V2.5 materializes a trusted auth context, global permission sourcing should be swapped behind `RbacService`; feature modules should keep depending on the adapter boundary.
|
||||||
|
|
||||||
|
Management overview reads only relation tables and summaries. It intentionally avoids AppData so it reflects the target backend boundary rather than the compatibility document store.
|
||||||
|
|||||||
@@ -587,3 +587,17 @@
|
|||||||
- V2.8:生产硬化稳定版 + 运维闭环。生产部署基线已存在,V2.8 聚焦备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册。
|
- V2.8:生产硬化稳定版 + 运维闭环。生产部署基线已存在,V2.8 聚焦备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册。
|
||||||
|
|
||||||
**理由**:这条链路保持了从低风险兼容到强一致主源的顺序。权限/审计必须随领域 CRUD 进入代码路径,否则后补会重写接口边界;AppData 退场必须有闸门和回滚价值,不能直接删除;性能基础要从 V2.4 的 API 设计开始,V2.6 只做规模化增强和后台化能力。这样每个阶段都有清晰验收物,也能避免长期双主源、无审计写入和大数据查询返工。
|
**理由**:这条链路保持了从低风险兼容到强一致主源的顺序。权限/审计必须随领域 CRUD 进入代码路径,否则后补会重写接口边界;AppData 退场必须有闸门和回滚价值,不能直接删除;性能基础要从 V2.4 的 API 设计开始,V2.6 只做规模化增强和后台化能力。这样每个阶段都有清晰验收物,也能避免长期双主源、无审计写入和大数据查询返工。
|
||||||
|
|
||||||
|
## 46. V2.7 协作治理先落稳定适配器,不硬编码临时权限
|
||||||
|
|
||||||
|
**问题**:V2.7 需要通知、评论、项目成员治理、管理驾驶舱和治理字典。但 V2.5 的完整后端 RBAC / audit 合同尚未以统一模块形式沉淀。如果各 V2.7 模块直接写临时权限判断和审计插入,后续 V2.5 收口会再次返工。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- 新增 `RbacService` 作为项目角色与全局权限断言适配器,Owner/Admin/Member/Viewer 的层级判断和 `management:view` / `governance:manage` 等全局权限入口集中在此处。
|
||||||
|
- 新增 `AuditService` 作为审计写入适配器,业务模块只提交 `actorId/action/resource/before/after`。
|
||||||
|
- 通知事件类型固定为 `assignment / mention / risk_alert / overdue_item`,跨模块通过这些稳定语义发通知。
|
||||||
|
- 通用评论使用 `entityType + entityId + entityVersionId` 的多态引用,不给每个业务表单独建评论表。
|
||||||
|
- 管理驾驶舱只读关系表和 `xiaobao_risk_summaries`,不回读 AppData。
|
||||||
|
- 治理字典使用软删除或使用中禁止硬删,变更必须写审计。
|
||||||
|
|
||||||
|
**理由**:适配器把“当前合同未完全落地”的不确定性隔离在一层,V2.7 能先交付企业协作能力,同时给 V2.5 后续权限/审计收口留下替换点。稳定事件名和多态评论引用能避免后续模块继续扩散 ad-hoc 字段。
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
# 开发路线图
|
# 开发路线图
|
||||||
|
|
||||||
## 当前阶段:V2.4 — 领域 CRUD 主写迁移
|
## 当前阶段:V2.7 — 企业级协作能力 + 管理治理
|
||||||
|
|
||||||
|
V2.7 的目标是在关系表主源方向上补齐企业协作和治理能力:通知、评论与提及、项目成员治理、管理驾驶舱、治理字典、以及统一 RBAC/audit 适配器。当前执行前提为 V2.4 已由用户确认完成;V2.7 不再新增 AppData 主存储。
|
||||||
|
|
||||||
|
### 当前重点
|
||||||
|
|
||||||
|
1. **协作通知**:通知记录、已读状态、NotificationBell,并覆盖 assignment / mention / risk_alert / overdue_item 稳定事件类型。
|
||||||
|
2. **通用评论**:DevTask/TestCase/Bug/Requirement/VersionPlan 统一评论面板,支持 `@成员名` 和显式成员选择,创建/删除写 audit。
|
||||||
|
3. **项目成员治理**:Owner/Admin/Member/Viewer 服务端强校验,禁止移除最后 Owner,角色变更写 audit。
|
||||||
|
4. **管理驾驶舱**:只读关系表和 summary,聚合活跃版本、逾期、阻塞、风险和成员负载。
|
||||||
|
5. **治理设置**:集中维护 task category、requirement type/platform/source,使用中的字典不可硬删,支持导入导出。
|
||||||
|
|
||||||
|
## 历史阶段:V2.4 — 领域 CRUD 主写迁移
|
||||||
|
|
||||||
V2.4 的目标是把业务主数据源从 AppData JSONB 文档切换到 PostgreSQL 领域关系表。AppData 继续保留为迁移、回填、兼容读取和排查入口,但不再作为长期主写入源;新增业务能力必须优先设计关系表、领域 CRUD API、索引/分区键和权限边界。V2.4 做逐领域主写迁移,并随 CRUD 入口埋好基础权限、`actorId` 和审计事件骨架;完整 RBAC、审计覆盖和 AppData 退场收口放到 V2.5。
|
V2.4 的目标是把业务主数据源从 AppData JSONB 文档切换到 PostgreSQL 领域关系表。AppData 继续保留为迁移、回填、兼容读取和排查入口,但不再作为长期主写入源;新增业务能力必须优先设计关系表、领域 CRUD API、索引/分区键和权限边界。V2.4 做逐领域主写迁移,并随 CRUD 入口埋好基础权限、`actorId` 和审计事件骨架;完整 RBAC、审计覆盖和 AppData 退场收口放到 V2.5。
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,21 @@ AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自
|
|||||||
|
|
||||||
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
||||||
|
|
||||||
|
## V2.7 协作治理工作流
|
||||||
|
|
||||||
|
通知统一进入 `notifications` 关系表,事件类型固定为:
|
||||||
|
|
||||||
|
- `assignment`:负责人或处理人被分配工作。
|
||||||
|
- `mention`:评论中 `@成员名` 或显式选择成员。
|
||||||
|
- `risk_alert`:小宝预警保存高风险快照后提醒管理者。
|
||||||
|
- `overdue_item`:逾期事项提醒。
|
||||||
|
|
||||||
|
评论统一使用 `CommentPanel`,支持 DevTask、TestCase、Bug、Requirement 和 VersionPlan。创建/删除评论必须写 audit;提及成员必须生成 mention 通知。
|
||||||
|
|
||||||
|
项目成员治理走 `/projects/:projectId/members` 服务端接口。角色为 Owner/Admin/Member/Viewer,Owner/Admin 可管理成员;服务端禁止移除或降级最后一个 Owner。版本成员可见性继续兼容旧 `version.members` 展示,但治理来源应逐步收敛到 ProjectMember。
|
||||||
|
|
||||||
|
管理驾驶舱 `/admin/management` 只查关系表和小宝 summary,不读取 AppData,并通过 RBAC adapter 校验 `management:view`。治理设置 `/admin/governance` 集中维护任务类型与需求字典;使用中的字典不可硬删,字典变更必须写 audit,并通过 RBAC adapter 校验 `governance:manage`。
|
||||||
|
|
||||||
## 日期选择与计划时间
|
## 日期选择与计划时间
|
||||||
|
|
||||||
- 调研、产品方案、UI 设计、开发任务、测试用例、Bug 创建时使用统一工作日日期时间选择器。
|
- 调研、产品方案、UI 设计、开发任务、测试用例、Bug 创建时使用统一工作日日期时间选择器。
|
||||||
|
|||||||
Reference in New Issue
Block a user