diff --git a/apps/gateway/src/federation/federation.module.ts b/apps/gateway/src/federation/federation.module.ts index a2e01ef..da77bbf 100644 --- a/apps/gateway/src/federation/federation.module.ts +++ b/apps/gateway/src/federation/federation.module.ts @@ -6,7 +6,7 @@ import { EnrollmentService } from './enrollment.service.js'; import { FederationController } from './federation.controller.js'; import { GrantsService } from './grants.service.js'; import { FederationClientService } from './client/index.js'; -import { FederationAuthGuard } from './server/index.js'; +import { FederationAuthGuard, FederationScopeService } from './server/index.js'; @Module({ controllers: [EnrollmentController, FederationController], @@ -17,6 +17,7 @@ import { FederationAuthGuard } from './server/index.js'; GrantsService, FederationClientService, FederationAuthGuard, + FederationScopeService, ], exports: [ CaService, @@ -24,6 +25,7 @@ import { FederationAuthGuard } from './server/index.js'; GrantsService, FederationClientService, FederationAuthGuard, + FederationScopeService, ], }) export class FederationModule {} diff --git a/apps/gateway/src/federation/server/__tests__/scope.service.spec.ts b/apps/gateway/src/federation/server/__tests__/scope.service.spec.ts new file mode 100644 index 0000000..0bbbe65 --- /dev/null +++ b/apps/gateway/src/federation/server/__tests__/scope.service.spec.ts @@ -0,0 +1,300 @@ +/** + * Unit tests for FederationScopeService (FED-M3-04). + * + * Coverage: + * - resource allowlist deny + * - excluded resource deny + * - invalid scope deny + * - invalid requested limit deny + * - native RBAC deny as subjectUserId + * - scope/native filter intersection for personal and team rows + * - max_rows_per_query cap + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js'; +import type { FederationContext } from '../federation-context.js'; + +const GRANT_ID = 'grant-1'; +const PEER_ID = 'peer-1'; +const SUBJECT_USER_ID = 'user-1'; + +function makeContext(scope: Record): FederationContext { + return { + grantId: GRANT_ID, + peerId: PEER_ID, + subjectUserId: SUBJECT_USER_ID, + scope, + }; +} + +function makeNativeRbac( + result: Awaited>, +): FederationNativeRbacEvaluator { + return { + evaluateReadAccess: vi.fn().mockResolvedValue(result), + }; +} + +describe('FederationScopeService', () => { + let service: FederationScopeService; + + beforeEach(() => { + service = new FederationScopeService(); + }); + + it('allows a granted resource and returns a capped query filter', async () => { + const nativeRbac = makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: ['team-1', 'team-2'] }, + }); + + const result = await service.evaluateAccess({ + context: makeContext({ + resources: ['tasks'], + filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } }, + max_rows_per_query: 50, + }), + resource: 'tasks', + requestedLimit: 500, + nativeRbac, + }); + + expect(result).toEqual({ + allowed: true, + filter: { + resource: 'tasks', + subjectUserId: SUBJECT_USER_ID, + includePersonal: true, + teamIds: ['team-1'], + limit: 50, + maxRowsPerQuery: 50, + }, + }); + expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({ + grantId: GRANT_ID, + peerId: PEER_ID, + subjectUserId: SUBJECT_USER_ID, + resource: 'tasks', + }); + }); + + it('defaults absent resource filters to native RBAC personal and team visibility', async () => { + const result = await service.evaluateAccess({ + context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }), + resource: 'notes', + nativeRbac: makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: ['team-1', 'team-2'] }, + }), + }); + + expect(result).toMatchObject({ + allowed: true, + filter: { + includePersonal: true, + teamIds: ['team-1', 'team-2'], + limit: 100, + }, + }); + }); + + it('honors include_personal false even when native RBAC allows personal rows', async () => { + const result = await service.evaluateAccess({ + context: makeContext({ + resources: ['memory'], + filters: { memory: { include_personal: false } }, + max_rows_per_query: 25, + }), + resource: 'memory', + nativeRbac: makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: [] }, + }), + }); + + expect(result).toMatchObject({ + allowed: true, + filter: { + includePersonal: false, + teamIds: [], + }, + }); + }); + + it('does not widen native RBAC when scope includes teams the user cannot access', async () => { + const result = await service.evaluateAccess({ + context: makeContext({ + resources: ['tasks'], + filters: { tasks: { include_teams: ['team-2'], include_personal: false } }, + max_rows_per_query: 25, + }), + resource: 'tasks', + nativeRbac: makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: ['team-1'] }, + }), + }); + + expect(result).toMatchObject({ + allowed: true, + filter: { + includePersonal: false, + teamIds: [], + }, + }); + }); + + it('denies invalid grant scope before RBAC evaluation', async () => { + const nativeRbac = makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: [] }, + }); + + const result = await service.evaluateAccess({ + context: makeContext({ resources: [], max_rows_per_query: 100 }), + resource: 'tasks', + nativeRbac, + }); + + expect(result).toMatchObject({ + allowed: false, + deny: { + code: 'invalid_scope', + stage: 'scope_parse', + statusCode: 400, + grantId: GRANT_ID, + subjectUserId: SUBJECT_USER_ID, + resource: 'tasks', + }, + }); + expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled(); + }); + + it('denies unsupported resource names before RBAC evaluation', async () => { + const nativeRbac = makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: [] }, + }); + + const result = await service.evaluateAccess({ + context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }), + resource: 'unknown_resource', + nativeRbac, + }); + + expect(result).toMatchObject({ + allowed: false, + deny: { + code: 'invalid_resource', + stage: 'resource_allowlist', + statusCode: 403, + }, + }); + expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled(); + }); + + it('denies resources explicitly present in excluded_resources before allowlist miss', async () => { + const nativeRbac = makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: [] }, + }); + + const result = await service.evaluateAccess({ + context: makeContext({ + resources: ['tasks'], + excluded_resources: ['credentials'], + max_rows_per_query: 100, + }), + resource: 'credentials', + nativeRbac, + }); + + expect(result).toMatchObject({ + allowed: false, + deny: { + code: 'resource_excluded', + stage: 'resource_exclusion', + statusCode: 403, + resource: 'credentials', + }, + }); + expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled(); + }); + + it('denies supported resources that are not granted by scope', async () => { + const nativeRbac = makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: [] }, + }); + + const result = await service.evaluateAccess({ + context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }), + resource: 'notes', + nativeRbac, + }); + + expect(result).toMatchObject({ + allowed: false, + deny: { + code: 'resource_not_granted', + stage: 'resource_allowlist', + statusCode: 403, + resource: 'notes', + }, + }); + expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled(); + }); + + it('denies invalid requested row limits before RBAC evaluation', async () => { + const nativeRbac = makeNativeRbac({ + allowed: true, + access: { includePersonal: true, teamIds: [] }, + }); + + const result = await service.evaluateAccess({ + context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }), + resource: 'tasks', + requestedLimit: 0, + nativeRbac, + }); + + expect(result).toMatchObject({ + allowed: false, + deny: { + code: 'invalid_limit', + stage: 'row_cap', + statusCode: 400, + details: { requestedLimit: 0 }, + }, + }); + expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled(); + }); + + it('denies when native RBAC rejects subjectUserId access to the resource', async () => { + const result = await service.evaluateAccess({ + context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }), + resource: 'tasks', + nativeRbac: makeNativeRbac({ + allowed: false, + reason: 'read:tasks denied', + details: { permission: 'tasks:read' }, + }), + }); + + expect(result).toEqual({ + allowed: false, + deny: { + code: 'native_rbac_denied', + stage: 'native_rbac', + statusCode: 403, + message: 'read:tasks denied', + grantId: GRANT_ID, + peerId: PEER_ID, + subjectUserId: SUBJECT_USER_ID, + resource: 'tasks', + details: { permission: 'tasks:read' }, + }, + }); + }); +}); diff --git a/apps/gateway/src/federation/server/index.ts b/apps/gateway/src/federation/server/index.ts index d369e00..aee7306 100644 --- a/apps/gateway/src/federation/server/index.ts +++ b/apps/gateway/src/federation/server/index.ts @@ -10,4 +10,22 @@ */ export { FederationAuthGuard } from './federation-auth.guard.js'; +export { FederationScopeService } from './scope.service.js'; export type { FederationContext } from './federation-context.js'; +export type { + FederationNativeRbacAccess, + FederationNativeRbacAllowedResult, + FederationNativeRbacDeniedResult, + FederationNativeRbacEvaluator, + FederationNativeRbacRequest, + FederationNativeRbacResult, + FederationScopeAllowedResult, + FederationScopeDeniedResult, + FederationScopeDenyCode, + FederationScopeDenyDetails, + FederationScopeDenyReason, + FederationScopeDenyStage, + FederationScopeEvaluationInput, + FederationScopeEvaluationResult, + FederationScopeQueryFilter, +} from './scope.service.js'; diff --git a/apps/gateway/src/federation/server/scope.service.ts b/apps/gateway/src/federation/server/scope.service.ts new file mode 100644 index 0000000..355d311 --- /dev/null +++ b/apps/gateway/src/federation/server/scope.service.ts @@ -0,0 +1,272 @@ +/** + * 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, + }, + }; + } +} diff --git a/docs/scratchpads/462-fed-m3-04-scope-service.md b/docs/scratchpads/462-fed-m3-04-scope-service.md new file mode 100644 index 0000000..ed5d10c --- /dev/null +++ b/docs/scratchpads/462-fed-m3-04-scope-service.md @@ -0,0 +1,60 @@ +# Scratchpad — FED-M3-04 Scope Service + +## Objective + +Implement `apps/gateway/src/federation/server/scope.service.ts` for the M3 inbound federation scope-enforcement pipeline. + +## Scope / Constraints + +- Task: FED-M3-04, issue #462. +- Branch: `feat/federation-m3-scope-service` from `origin/main` @ 0.0.48. +- Pure service: no direct DB access; native RBAC/data access is injected per evaluation call. +- Reuse `parseFederationScope` from M2-03. +- Workers do not edit `docs/federation/TASKS.md` per repo AGENTS.md. + +## Acceptance Criteria + +1. Resource allowlist and `excluded_resources` enforced. +2. Native RBAC evaluated as `subjectUserId` through an injected evaluator. +3. Scope filter intersection supports `include_teams` and `include_personal` without widening native RBAC. +4. `max_rows_per_query` caps requested limits. +5. Service returns `{ allowed: true, filter }` or a structured deny reason usable by M4 audit. +6. Unit tests cover every deny path. + +## Plan + +1. Inspect existing federation scope/schema/auth guard contracts. +2. Add pure `FederationScopeService` plus typed result/filter/deny interfaces. +3. Add focused unit tests for happy paths, filter intersection, row cap, and deny paths. +4. Export/register service for future verb controllers. +5. Run situational tests, baseline gates, code review, then PR. + +## Budget + +- Provided model tier: sonnet. +- Estimate from task row: 10K tokens. +- Working cap assumption: keep implementation focused to FED-M3-04 surfaces only. + +## Progress + +- Intake complete; dirty base worktree avoided by creating isolated worktree at `/home/jarvis/src/mosaic-mono-v1-fed-m3-04`. +- Project PRD and federation task spec reviewed. +- Added `FederationScopeService` with structured allow/deny result types and injected native RBAC evaluator contract. +- Added unit coverage for happy path, row cap, filter intersection, and every deny path. +- Exported/registered the service for upcoming M3 verb controllers. + +## Verification Evidence + +- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/__tests__/scope.service.spec.ts` — pass (10 tests). +- `pnpm build` — pass (23 successful tasks). +- `pnpm typecheck` — pass (41 successful tasks). +- `pnpm lint` — pass (23 successful tasks). +- `pnpm format:check` — pass. +- `pnpm test` — pass after starting local `postgres`/`valkey` and running `pnpm --filter @mosaicstack/db db:push` for the DB-backed cross-user isolation suite (41 successful tasks; gateway 477 passed / 11 skipped). +- Code review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings. +- Security review: `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — risk none, 0 findings. + +## Risks / Blockers + +- Issue #462 is already closed in provider output; likely milestone tracking mismatch. Will still reference #462 in PR body unless orchestrator redirects. +- Local full-test setup required `docker compose up -d postgres valkey` + `db:push`; containers were stopped with `docker compose down` after verification.