diff --git a/apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts b/apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts new file mode 100644 index 0000000..9ccdec8 --- /dev/null +++ b/apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts @@ -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'] }, + }); + }); +}); diff --git a/apps/server/src/modules/ai/analysis/permission-scope-resolver.ts b/apps/server/src/modules/ai/analysis/permission-scope-resolver.ts new file mode 100644 index 0000000..8d99b21 --- /dev/null +++ b/apps/server/src/modules/ai/analysis/permission-scope-resolver.ts @@ -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 { + 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 { + 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 { + 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> { + 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)))); +}