feat(ai-analysis): 添加分析权限范围解析
This commit is contained in:
@@ -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'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
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