/** * FederationScopeService — M3 server-side scope enforcement pipeline. * * Pure trust-boundary service: it validates the grant scope, asks an injected * native RBAC evaluator what the subject user can read locally, intersects that * answer with the federation scope filters, and returns a query filter for the * verb controllers. The service performs no DB calls directly. */ import { Injectable } from '@nestjs/common'; import { FEDERATION_RESOURCE_VALUES, type FederationResource, FederationScopeError, parseFederationScope, } from '../scope-schema.js'; import type { FederationContext } from './federation-context.js'; const federationResourceSet: ReadonlySet = new Set(FEDERATION_RESOURCE_VALUES); export type FederationScopeDenyStage = | 'scope_parse' | 'resource_allowlist' | 'resource_exclusion' | 'native_rbac' | 'row_cap'; export type FederationScopeDenyCode = | 'invalid_scope' | 'invalid_resource' | 'resource_not_granted' | 'resource_excluded' | 'native_rbac_denied' | 'invalid_limit'; export type FederationScopeDenyStatus = 400 | 403; export interface FederationScopeDenyDetails { readonly [key: string]: string | number | boolean | readonly string[]; } export interface FederationScopeDenyReason { readonly code: FederationScopeDenyCode; readonly stage: FederationScopeDenyStage; readonly statusCode: FederationScopeDenyStatus; readonly message: string; readonly grantId: string; readonly peerId: string; readonly subjectUserId: string; readonly resource: string; readonly details?: FederationScopeDenyDetails; } export interface FederationNativeRbacRequest { readonly grantId: string; readonly peerId: string; readonly subjectUserId: string; readonly resource: FederationResource; } export interface FederationNativeRbacAccess { /** Whether this user may read personal rows for this resource. */ readonly includePersonal: boolean; /** Team IDs this user may read for this resource under native RBAC. */ readonly teamIds: readonly string[]; } export interface FederationNativeRbacAllowedResult { readonly allowed: true; readonly access: FederationNativeRbacAccess; } export interface FederationNativeRbacDeniedResult { readonly allowed: false; readonly reason?: string; readonly details?: FederationScopeDenyDetails; } export type FederationNativeRbacResult = | FederationNativeRbacAllowedResult | FederationNativeRbacDeniedResult; export interface FederationNativeRbacEvaluator { evaluateReadAccess(request: FederationNativeRbacRequest): Promise; } export interface FederationScopeEvaluationInput { readonly context: FederationContext; readonly resource: string; readonly requestedLimit?: number; readonly nativeRbac: FederationNativeRbacEvaluator; } export interface FederationScopeQueryFilter { readonly resource: FederationResource; readonly subjectUserId: string; readonly includePersonal: boolean; readonly teamIds: readonly string[]; readonly limit: number; readonly maxRowsPerQuery: number; } export interface FederationScopeAllowedResult { readonly allowed: true; readonly filter: FederationScopeQueryFilter; } export interface FederationScopeDeniedResult { readonly allowed: false; readonly deny: FederationScopeDenyReason; } export type FederationScopeEvaluationResult = | FederationScopeAllowedResult | FederationScopeDeniedResult; function isFederationResource(resource: string): resource is FederationResource { return federationResourceSet.has(resource); } function uniqueStrings(values: readonly string[]): readonly string[] { return Array.from(new Set(values)); } function intersectTeamIds( nativeTeamIds: readonly string[], scopedTeamIds: readonly string[] | undefined, ): readonly string[] { const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds); if (scopedTeamIds === undefined) { return uniqueNativeTeamIds; } const nativeSet = new Set(uniqueNativeTeamIds); return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId)); } function makeDenyReason(params: { readonly code: FederationScopeDenyCode; readonly stage: FederationScopeDenyStage; readonly statusCode?: FederationScopeDenyStatus; readonly message: string; readonly context: FederationContext; readonly resource: string; readonly details?: FederationScopeDenyDetails; }): FederationScopeDeniedResult { return { allowed: false, deny: { code: params.code, stage: params.stage, statusCode: params.statusCode ?? 403, message: params.message, grantId: params.context.grantId, peerId: params.context.peerId, subjectUserId: params.context.subjectUserId, resource: params.resource, ...(params.details !== undefined ? { details: params.details } : {}), }, }; } @Injectable() export class FederationScopeService { async evaluateAccess( input: FederationScopeEvaluationInput, ): Promise { const { context, resource, requestedLimit, nativeRbac } = input; let scope: ReturnType; try { scope = parseFederationScope(context.scope); } catch (error: unknown) { const message = error instanceof FederationScopeError ? 'Federation grant scope is invalid' : 'Federation grant scope could not be parsed'; const details = error instanceof Error ? { reason: error.message } : undefined; return makeDenyReason({ code: 'invalid_scope', stage: 'scope_parse', statusCode: 400, message, context, resource, ...(details !== undefined ? { details } : {}), }); } if (!isFederationResource(resource)) { return makeDenyReason({ code: 'invalid_resource', stage: 'resource_allowlist', message: 'Requested federation resource is not supported', context, resource, details: { supportedResources: FEDERATION_RESOURCE_VALUES }, }); } if (scope.excluded_resources.includes(resource)) { return makeDenyReason({ code: 'resource_excluded', stage: 'resource_exclusion', message: 'Requested federation resource is explicitly excluded by grant scope', context, resource, }); } if (!scope.resources.includes(resource)) { return makeDenyReason({ code: 'resource_not_granted', stage: 'resource_allowlist', message: 'Requested federation resource is not granted by scope', context, resource, details: { grantedResources: scope.resources }, }); } if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) { return makeDenyReason({ code: 'invalid_limit', stage: 'row_cap', statusCode: 400, message: 'Requested row limit must be a positive integer', context, resource, details: { requestedLimit }, }); } const nativeResult = await nativeRbac.evaluateReadAccess({ grantId: context.grantId, peerId: context.peerId, subjectUserId: context.subjectUserId, resource, }); if (!nativeResult.allowed) { return makeDenyReason({ code: 'native_rbac_denied', stage: 'native_rbac', message: nativeResult.reason ?? 'Subject user is not allowed to read this resource', context, resource, ...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}), }); } const scopeFilter = scope.filters?.[resource]; const includePersonal = Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal; const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams); const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query); return { allowed: true, filter: { resource, subjectUserId: context.subjectUserId, includePersonal, teamIds, limit, maxRowsPerQuery: scope.max_rows_per_query, }, }; } }