feat(ai-analysis): 添加分析权限范围解析
This commit is contained in:
165
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
Normal file
165
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { AnalysisRequest, DataScope } from '@ftb/shared';
|
||||
import { RbacService } from '../../../common/rbac/rbac.service';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
|
||||
export interface AnalysisScopeInput {
|
||||
actorId?: string;
|
||||
permissions: string[];
|
||||
context?: AnalysisRequest['context'];
|
||||
}
|
||||
|
||||
type ScopedWhere = {
|
||||
productId?: string;
|
||||
projectId?: string | { in: string[] };
|
||||
versionId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PermissionScopeResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly rbac: RbacService,
|
||||
) {}
|
||||
|
||||
async resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope> {
|
||||
const actorId = input.actorId?.trim();
|
||||
if (!actorId) throw new ForbiddenException('Missing actor scope');
|
||||
|
||||
const permissions = input.permissions ?? [];
|
||||
const context = input.context;
|
||||
|
||||
if (context?.surface === 'version_detail' && context.versionId) {
|
||||
return this.resolveVersionScope(actorId, permissions, context.versionId);
|
||||
}
|
||||
|
||||
if (context?.surface === 'project_detail' && context.projectId) {
|
||||
await this.rbac.assertProjectRole({
|
||||
actorId,
|
||||
projectId: context.projectId,
|
||||
allowedRoles: ['viewer'],
|
||||
permissions,
|
||||
});
|
||||
return { type: 'project', projectId: context.projectId };
|
||||
}
|
||||
|
||||
if (context?.surface === 'product_detail' && context.productId) {
|
||||
return this.resolveProductScope(actorId, permissions, context.productId);
|
||||
}
|
||||
|
||||
if (permissions.includes('*')) {
|
||||
await this.rbac.assertGlobalPermission({
|
||||
actorId,
|
||||
permissions,
|
||||
requiredPermissions: ['management:view'],
|
||||
});
|
||||
return { type: 'system', reason: 'admin' };
|
||||
}
|
||||
|
||||
if (permissions.includes('management:view')) {
|
||||
await this.rbac.assertGlobalPermission({
|
||||
actorId,
|
||||
permissions,
|
||||
requiredPermissions: ['management:view'],
|
||||
});
|
||||
return this.resolveManagedProjectsScope(actorId);
|
||||
}
|
||||
|
||||
const managedScope = await this.resolveManagedProjectsScope(actorId);
|
||||
if (managedScope.projectIds.length > 0) return managedScope;
|
||||
|
||||
return { type: 'self', userId: actorId };
|
||||
}
|
||||
|
||||
private async resolveVersionScope(
|
||||
actorId: string,
|
||||
permissions: string[],
|
||||
versionId: string,
|
||||
): Promise<DataScope> {
|
||||
const version = await this.prisma.version.findFirst({
|
||||
where: { id: versionId },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
if (!version) throw new NotFoundException('Version not found');
|
||||
|
||||
if (!version.projectId) {
|
||||
await this.rbac.assertGlobalPermission({
|
||||
actorId,
|
||||
permissions,
|
||||
requiredPermissions: ['version:view', 'management:view'],
|
||||
});
|
||||
return { type: 'version', versionId: version.id };
|
||||
}
|
||||
|
||||
await this.rbac.assertProjectRole({
|
||||
actorId,
|
||||
projectId: version.projectId,
|
||||
allowedRoles: ['viewer'],
|
||||
permissions,
|
||||
});
|
||||
return { type: 'version', versionId: version.id };
|
||||
}
|
||||
|
||||
private async resolveProductScope(
|
||||
actorId: string,
|
||||
permissions: string[],
|
||||
productId: string,
|
||||
): Promise<DataScope> {
|
||||
if (permissions.includes('*')) {
|
||||
await this.rbac.assertGlobalPermission({
|
||||
actorId,
|
||||
permissions,
|
||||
requiredPermissions: ['management:view'],
|
||||
});
|
||||
return { type: 'product', productId };
|
||||
}
|
||||
|
||||
if (permissions.includes('management:view') || permissions.includes('product:view')) {
|
||||
try {
|
||||
await this.rbac.assertGlobalPermission({
|
||||
actorId,
|
||||
permissions,
|
||||
requiredPermissions: ['management:view', 'product:view'],
|
||||
});
|
||||
return { type: 'product', productId };
|
||||
} catch {
|
||||
// Fall through to project membership narrowing.
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await this.prisma.projectMember.findMany({
|
||||
where: {
|
||||
userId: actorId,
|
||||
project: { productId },
|
||||
},
|
||||
select: { projectId: true },
|
||||
});
|
||||
const projectIds = uniqueProjectIds(rows);
|
||||
if (projectIds.length === 0) throw new ForbiddenException('No product analysis scope');
|
||||
|
||||
return { type: 'managed_projects', projectIds };
|
||||
}
|
||||
|
||||
private async resolveManagedProjectsScope(actorId: string): Promise<Extract<DataScope, { type: 'managed_projects' }>> {
|
||||
const rows = await this.prisma.projectMember.findMany({
|
||||
where: {
|
||||
userId: actorId,
|
||||
role: { in: ['owner', 'admin'] },
|
||||
},
|
||||
select: { projectId: true },
|
||||
});
|
||||
return { type: 'managed_projects', projectIds: uniqueProjectIds(rows) };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildScopedWhere(scope: DataScope): ScopedWhere {
|
||||
if (scope.type === 'product') return { productId: scope.productId };
|
||||
if (scope.type === 'project') return { projectId: scope.projectId };
|
||||
if (scope.type === 'version') return { versionId: scope.versionId };
|
||||
if (scope.type === 'managed_projects') return { projectId: { in: scope.projectIds } };
|
||||
return {};
|
||||
}
|
||||
|
||||
function uniqueProjectIds(rows: Array<{ projectId: string | null | undefined }>): string[] {
|
||||
return Array.from(new Set(rows.map((row) => row.projectId).filter((projectId): projectId is string => Boolean(projectId))));
|
||||
}
|
||||
Reference in New Issue
Block a user