166 lines
5.1 KiB
TypeScript
166 lines
5.1 KiB
TypeScript
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))));
|
|
}
|