From 7d7cf012f0ac4212a433996b6a0fc3d6f032d881 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Wed, 22 Apr 2026 02:31:13 +0000 Subject: [PATCH] feat(federation): scope schema validator [FED-M2-03] (#489) --- .../src/federation/scope-schema.spec.ts | 187 ++++++++++++++++++ apps/gateway/src/federation/scope-schema.ts | 147 ++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 apps/gateway/src/federation/scope-schema.spec.ts create mode 100644 apps/gateway/src/federation/scope-schema.ts diff --git a/apps/gateway/src/federation/scope-schema.spec.ts b/apps/gateway/src/federation/scope-schema.spec.ts new file mode 100644 index 0000000..8598c00 --- /dev/null +++ b/apps/gateway/src/federation/scope-schema.spec.ts @@ -0,0 +1,187 @@ +/** + * Unit tests for FederationScopeSchema and parseFederationScope. + * + * Coverage: + * - Valid: minimal scope + * - Valid: full PRD §8.1 example + * - Valid: resources + excluded_resources (no overlap) + * - Invalid: empty resources + * - Invalid: unknown resource value + * - Invalid: resources / excluded_resources intersection + * - Invalid: filter key not in resources + * - Invalid: max_rows_per_query = 0 + * - Invalid: max_rows_per_query = 10001 + * - Invalid: not an object / null + * - Defaults: include_personal defaults to true; excluded_resources defaults to [] + * - Sentinel: console.warn fires for sensitive resources + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + parseFederationScope, + FederationScopeError, + FederationScopeSchema, +} from './scope-schema.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('parseFederationScope — valid inputs', () => { + it('accepts a minimal scope (resources + max_rows_per_query only)', () => { + const scope = parseFederationScope({ + resources: ['tasks'], + max_rows_per_query: 100, + }); + expect(scope.resources).toEqual(['tasks']); + expect(scope.max_rows_per_query).toBe(100); + expect(scope.excluded_resources).toEqual([]); + expect(scope.filters).toBeUndefined(); + }); + + it('accepts the full PRD §8.1 example', () => { + const scope = parseFederationScope({ + resources: ['tasks', 'notes', 'memory'], + filters: { + tasks: { include_teams: ['team_uuid_1', 'team_uuid_2'], include_personal: true }, + notes: { include_personal: true, include_teams: [] }, + memory: { include_personal: true }, + }, + excluded_resources: ['credentials', 'api_keys'], + max_rows_per_query: 500, + }); + expect(scope.resources).toEqual(['tasks', 'notes', 'memory']); + expect(scope.excluded_resources).toEqual(['credentials', 'api_keys']); + expect(scope.filters?.tasks?.include_teams).toEqual(['team_uuid_1', 'team_uuid_2']); + expect(scope.max_rows_per_query).toBe(500); + }); + + it('accepts a scope with excluded_resources and no filter overlap', () => { + const scope = parseFederationScope({ + resources: ['tasks', 'notes'], + excluded_resources: ['memory'], + max_rows_per_query: 250, + }); + expect(scope.resources).toEqual(['tasks', 'notes']); + expect(scope.excluded_resources).toEqual(['memory']); + }); +}); + +describe('parseFederationScope — defaults', () => { + it('defaults excluded_resources to []', () => { + const scope = parseFederationScope({ resources: ['tasks'], max_rows_per_query: 1 }); + expect(scope.excluded_resources).toEqual([]); + }); + + it('defaults include_personal to true when filter is provided without it', () => { + const scope = parseFederationScope({ + resources: ['tasks'], + filters: { tasks: { include_teams: ['t1'] } }, + max_rows_per_query: 10, + }); + expect(scope.filters?.tasks?.include_personal).toBe(true); + }); +}); + +describe('parseFederationScope — invalid inputs', () => { + it('throws FederationScopeError for empty resources array', () => { + expect(() => parseFederationScope({ resources: [], max_rows_per_query: 100 })).toThrow( + FederationScopeError, + ); + }); + + it('throws for unknown resource value in resources', () => { + expect(() => + parseFederationScope({ resources: ['unknown_resource'], max_rows_per_query: 100 }), + ).toThrow(FederationScopeError); + }); + + it('throws when resources and excluded_resources intersect', () => { + expect(() => + parseFederationScope({ + resources: ['tasks', 'memory'], + excluded_resources: ['memory'], + max_rows_per_query: 100, + }), + ).toThrow(FederationScopeError); + }); + + it('throws when filters references a resource not in resources', () => { + expect(() => + parseFederationScope({ + resources: ['tasks'], + filters: { notes: { include_personal: true } }, + max_rows_per_query: 100, + }), + ).toThrow(FederationScopeError); + }); + + it('throws for max_rows_per_query = 0', () => { + expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 0 })).toThrow( + FederationScopeError, + ); + }); + + it('throws for max_rows_per_query = 10001', () => { + expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 10001 })).toThrow( + FederationScopeError, + ); + }); + + it('throws for null input', () => { + expect(() => parseFederationScope(null)).toThrow(FederationScopeError); + }); + + it('throws for non-object input (string)', () => { + expect(() => parseFederationScope('not-an-object')).toThrow(FederationScopeError); + }); +}); + +describe('parseFederationScope — sentinel warning', () => { + it('emits console.warn when resources includes "credentials"', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + parseFederationScope({ + resources: ['tasks', 'credentials'], + max_rows_per_query: 100, + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + '[FederationScope] WARNING: scope grants sensitive resource "credentials"', + ), + ); + }); + + it('emits console.warn when resources includes "api_keys"', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + parseFederationScope({ + resources: ['tasks', 'api_keys'], + max_rows_per_query: 100, + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + '[FederationScope] WARNING: scope grants sensitive resource "api_keys"', + ), + ); + }); + + it('does NOT emit console.warn for non-sensitive resources', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + parseFederationScope({ resources: ['tasks', 'notes', 'memory'], max_rows_per_query: 100 }); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe('FederationScopeSchema — boundary values', () => { + it('accepts max_rows_per_query = 1 (lower bound)', () => { + const result = FederationScopeSchema.safeParse({ resources: ['tasks'], max_rows_per_query: 1 }); + expect(result.success).toBe(true); + }); + + it('accepts max_rows_per_query = 10000 (upper bound)', () => { + const result = FederationScopeSchema.safeParse({ + resources: ['tasks'], + max_rows_per_query: 10000, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/apps/gateway/src/federation/scope-schema.ts b/apps/gateway/src/federation/scope-schema.ts new file mode 100644 index 0000000..c0e0bc6 --- /dev/null +++ b/apps/gateway/src/federation/scope-schema.ts @@ -0,0 +1,147 @@ +/** + * Federation grant scope schema and validator. + * + * Source of truth: docs/federation/PRD.md §8.1 + * + * This module is intentionally pure — no DB, no NestJS, no CA wiring. + * It is reusable from grant CRUD (M2-06) and scope enforcement (M3+). + */ + +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// Allowlist of federation resources (canonical — M3+ will extend this list) +// --------------------------------------------------------------------------- +export const FEDERATION_RESOURCE_VALUES = [ + 'tasks', + 'notes', + 'memory', + 'credentials', + 'api_keys', +] as const; + +export type FederationResource = (typeof FEDERATION_RESOURCE_VALUES)[number]; + +/** + * Sensitive resources require explicit admin approval (PRD §8.4). + * The parser warns when these appear in `resources`; M2-06 grant CRUD + * will add a hard gate on top of this warning. + */ +const SENSITIVE_RESOURCES: ReadonlySet = new Set(['credentials', 'api_keys']); + +// --------------------------------------------------------------------------- +// Sub-schemas +// --------------------------------------------------------------------------- + +const ResourceArraySchema = z + .array(z.enum(FEDERATION_RESOURCE_VALUES)) + .nonempty({ message: 'resources must contain at least one value' }) + .refine((arr) => new Set(arr).size === arr.length, { + message: 'resources must not contain duplicate values', + }); + +const ResourceFilterSchema = z.object({ + include_teams: z.array(z.string()).optional(), + include_personal: z.boolean().default(true), +}); + +// --------------------------------------------------------------------------- +// Top-level schema +// --------------------------------------------------------------------------- + +export const FederationScopeSchema = z + .object({ + resources: ResourceArraySchema, + + excluded_resources: z + .array(z.enum(FEDERATION_RESOURCE_VALUES)) + .default([]) + .refine((arr) => new Set(arr).size === arr.length, { + message: 'excluded_resources must not contain duplicate values', + }), + + filters: z.record(z.string(), ResourceFilterSchema).optional(), + + max_rows_per_query: z + .number() + .int({ message: 'max_rows_per_query must be an integer' }) + .min(1, { message: 'max_rows_per_query must be at least 1' }) + .max(10000, { message: 'max_rows_per_query must be at most 10000' }), + }) + .superRefine((data, ctx) => { + const resourceSet = new Set(data.resources); + + // Intersection guard: a resource cannot be both granted and excluded + for (const r of data.excluded_resources) { + if (resourceSet.has(r)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Resource "${r}" appears in both resources and excluded_resources`, + path: ['excluded_resources'], + }); + } + } + + // Filter keys must be a subset of resources + if (data.filters) { + for (const key of Object.keys(data.filters)) { + if (!resourceSet.has(key as FederationResource)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `filters key "${key}" references a resource not present in resources`, + path: ['filters', key], + }); + } + } + } + }); + +export type FederationScope = z.infer; + +// --------------------------------------------------------------------------- +// Error class +// --------------------------------------------------------------------------- + +export class FederationScopeError extends Error { + constructor(message: string) { + super(message); + this.name = 'FederationScopeError'; + } +} + +// --------------------------------------------------------------------------- +// Typed parser +// --------------------------------------------------------------------------- + +/** + * Parse and validate an unknown value as a FederationScope. + * + * Throws `FederationScopeError` with aggregated Zod issues on failure. + * + * Emits `console.warn` when sensitive resources (`credentials`, `api_keys`) + * are present in `resources` — per PRD §8.4, these require explicit admin + * approval. M2-06 grant CRUD will add a hard gate on top of this warning. + */ +export function parseFederationScope(input: unknown): FederationScope { + const result = FederationScopeSchema.safeParse(input); + + if (!result.success) { + const issues = result.error.issues + .map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`) + .join('\n'); + throw new FederationScopeError(`Invalid federation scope:\n${issues}`); + } + + const scope = result.data; + + // Sentinel warning for sensitive resources (PRD §8.4) + for (const resource of scope.resources) { + if (SENSITIVE_RESOURCES.has(resource)) { + console.warn( + `[FederationScope] WARNING: scope grants sensitive resource "${resource}". Per PRD §8.4 this requires explicit admin approval and is logged.`, + ); + } + } + + return scope; +}