feat(ai-analysis): 添加分析权限范围解析

This commit is contained in:
2026-07-08 21:08:25 +08:00
parent 89ee44422b
commit 889a34cfa1
2 changed files with 257 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import { ForbiddenException } from '@nestjs/common';
import { buildScopedWhere, PermissionScopeResolver } from './permission-scope-resolver';
describe('PermissionScopeResolver', () => {
function makeResolver() {
const prisma = {
project: { findFirst: jest.fn() },
version: { findFirst: jest.fn() },
projectMember: { findMany: jest.fn(), findUnique: jest.fn() },
};
const rbac = {
assertGlobalPermission: jest.fn(),
assertProjectRole: jest.fn(),
};
return { prisma, rbac, resolver: new PermissionScopeResolver(prisma as any, rbac as any) };
}
it('returns system scope for wildcard permissions', async () => {
const { rbac, resolver } = makeResolver();
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-8', via: 'system' });
await expect(resolver.resolveAnalysisScope({
actorId: 'm-8',
permissions: ['*'],
context: { surface: 'ai_assistant' },
})).resolves.toEqual({ type: 'system', reason: 'admin' });
});
it('returns managed project scope for management permission', async () => {
const { prisma, rbac, resolver } = makeResolver();
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-pm', via: 'permission' });
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }, { projectId: 'project-2' }]);
await expect(resolver.resolveAnalysisScope({
actorId: 'm-pm',
permissions: ['management:view'],
context: { surface: 'ai_assistant' },
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
});
it('restricts version context to the requested version when the user has page access', async () => {
const { prisma, rbac, resolver } = makeResolver();
prisma.version.findFirst.mockResolvedValue({ id: 'version-1', projectId: 'project-1' });
rbac.assertProjectRole.mockResolvedValue({
actorId: 'm-dev',
projectId: 'project-1',
role: 'member',
via: 'project_member',
});
await expect(resolver.resolveAnalysisScope({
actorId: 'm-dev',
permissions: [],
context: { surface: 'version_detail', versionId: 'version-1' },
})).resolves.toEqual({ type: 'version', versionId: 'version-1' });
});
it('narrows product context to project memberships when the actor lacks global product access', async () => {
const { prisma, rbac, resolver } = makeResolver();
rbac.assertGlobalPermission.mockRejectedValue(new ForbiddenException('No product permission'));
prisma.projectMember.findMany.mockResolvedValue([
{ projectId: 'project-1' },
{ projectId: 'project-1' },
{ projectId: 'project-2' },
]);
await expect(resolver.resolveAnalysisScope({
actorId: 'm-dev',
permissions: [],
context: { surface: 'product_detail', productId: 'product-1' },
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
});
it('rejects missing actor id', async () => {
const { resolver } = makeResolver();
await expect(resolver.resolveAnalysisScope({
actorId: '',
permissions: [],
context: { surface: 'ai_assistant' },
})).rejects.toBeInstanceOf(ForbiddenException);
});
it('builds relation-table where filters for concrete scopes', () => {
expect(buildScopedWhere({ type: 'product', productId: 'product-1' })).toEqual({ productId: 'product-1' });
expect(buildScopedWhere({ type: 'project', projectId: 'project-1' })).toEqual({ projectId: 'project-1' });
expect(buildScopedWhere({ type: 'version', versionId: 'version-1' })).toEqual({ versionId: 'version-1' });
expect(buildScopedWhere({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] })).toEqual({
projectId: { in: ['project-1', 'project-2'] },
});
});
});

View 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))));
}