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