diff --git a/apps/gateway/src/federation/federation.module.ts b/apps/gateway/src/federation/federation.module.ts index a1ed09d..2fe9f77 100644 --- a/apps/gateway/src/federation/federation.module.ts +++ b/apps/gateway/src/federation/federation.module.ts @@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js'; import { EnrollmentService } from './enrollment.service.js'; import { FederationController } from './federation.controller.js'; import { CapabilitiesController } from './server/verbs/capabilities.controller.js'; +import { GetController } from './server/verbs/get.controller.js'; +import { FederationGetQueryService } from './server/verbs/get-query.service.js'; import { GrantsService } from './grants.service.js'; import { FederationClientService, QuerySourceService } from './client/index.js'; import { FederationAuthGuard, FederationScopeService } from './server/index.js'; @@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js'; import { FederationListQueryService } from './server/verbs/list-query.service.js'; @Module({ - controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController], + controllers: [ + EnrollmentController, + FederationController, + CapabilitiesController, + ListController, + GetController, + ], providers: [ AdminGuard, CaService, @@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js FederationAuthGuard, FederationScopeService, FederationListQueryService, + FederationGetQueryService, ], exports: [ CaService, @@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js FederationAuthGuard, FederationScopeService, FederationListQueryService, + FederationGetQueryService, ], }) export class FederationModule {} diff --git a/apps/gateway/src/federation/server/verbs/__tests__/get-query.service.spec.ts b/apps/gateway/src/federation/server/verbs/__tests__/get-query.service.spec.ts new file mode 100644 index 0000000..7c78f3c --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/__tests__/get-query.service.spec.ts @@ -0,0 +1,348 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + createPgliteDb, + missionTasks, + missions, + projects, + runPgliteMigrations, + teams, + users, + type Db, + type DbHandle, +} from '@mosaicstack/db'; +import type { FederationScopeQueryFilter } from '../../scope.service.js'; +import { FederationGetQueryService } from '../get-query.service.js'; + +const CREDENTIAL_FILTER: FederationScopeQueryFilter = { + resource: 'credentials', + subjectUserId: 'user-1', + includePersonal: true, + teamIds: [], + limit: 1, + maxRowsPerQuery: 25, +}; + +const SUBJECT_USER_ID = 'fed-m3-06-subject'; +const OTHER_USER_ID = 'fed-m3-06-other'; +const TEAM_ID = '06000000-0000-4000-8000-000000000001'; +const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002'; +const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101'; +const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102'; +const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103'; +const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201'; +const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202'; +const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203'; +const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301'; +const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302'; +const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303'; +const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304'; + +let dbHandle: DbHandle | undefined; + +function makeService() { + return new FederationGetQueryService({} as Db); +} + +function makeDbService() { + if (!dbHandle) { + throw new Error('test DB not initialized'); + } + return new FederationGetQueryService(dbHandle.db); +} + +async function seedNotesFixture() { + if (!dbHandle) { + throw new Error('test DB not initialized'); + } + + await dbHandle.db.insert(users).values([ + { + id: SUBJECT_USER_ID, + name: 'Federation Subject', + email: `${SUBJECT_USER_ID}@example.test`, + emailVerified: false, + }, + { + id: OTHER_USER_ID, + name: 'Federation Other', + email: `${OTHER_USER_ID}@example.test`, + emailVerified: false, + }, + ]); + + await dbHandle.db.insert(teams).values([ + { + id: TEAM_ID, + name: 'FED-M3-06 Team', + slug: 'fed-m3-06-team', + ownerId: SUBJECT_USER_ID, + managerId: SUBJECT_USER_ID, + }, + { + id: UNAUTHORIZED_TEAM_ID, + name: 'FED-M3-06 Unauthorized Team', + slug: 'fed-m3-06-unauthorized-team', + ownerId: OTHER_USER_ID, + managerId: OTHER_USER_ID, + }, + ]); + + await dbHandle.db.insert(projects).values([ + { + id: PERSONAL_PROJECT_ID, + name: 'FED-M3-06 Personal Project', + ownerId: SUBJECT_USER_ID, + ownerType: 'user', + }, + { + id: TEAM_PROJECT_ID, + name: 'FED-M3-06 Team Project', + teamId: TEAM_ID, + ownerType: 'team', + }, + { + id: UNAUTHORIZED_PROJECT_ID, + name: 'FED-M3-06 Unauthorized Project', + teamId: UNAUTHORIZED_TEAM_ID, + ownerType: 'team', + }, + ]); + + await dbHandle.db.insert(missions).values([ + { + id: PERSONAL_MISSION_ID, + name: 'FED-M3-06 Personal Mission', + projectId: PERSONAL_PROJECT_ID, + userId: SUBJECT_USER_ID, + }, + { + id: TEAM_MISSION_ID, + name: 'FED-M3-06 Team Mission', + projectId: TEAM_PROJECT_ID, + userId: SUBJECT_USER_ID, + }, + { + id: UNAUTHORIZED_MISSION_ID, + name: 'FED-M3-06 Unauthorized Mission', + projectId: UNAUTHORIZED_PROJECT_ID, + userId: SUBJECT_USER_ID, + }, + ]); + + await dbHandle.db.insert(missionTasks).values([ + { + id: SUBJECT_TEAM_NOTE_ID, + missionId: TEAM_MISSION_ID, + userId: SUBJECT_USER_ID, + notes: 'subject note on team mission', + createdAt: new Date('2026-06-24T03:00:00.000Z'), + updatedAt: new Date('2026-06-24T03:00:00.000Z'), + }, + { + id: OTHER_TEAM_NOTE_ID, + missionId: TEAM_MISSION_ID, + userId: OTHER_USER_ID, + notes: 'other user note on team mission', + createdAt: new Date('2026-06-24T02:00:00.000Z'), + updatedAt: new Date('2026-06-24T02:00:00.000Z'), + }, + { + id: SUBJECT_PERSONAL_NOTE_ID, + missionId: PERSONAL_MISSION_ID, + userId: SUBJECT_USER_ID, + notes: 'subject note on personal mission', + createdAt: new Date('2026-06-24T01:00:00.000Z'), + updatedAt: new Date('2026-06-24T01:00:00.000Z'), + }, + { + id: SUBJECT_UNAUTHORIZED_NOTE_ID, + missionId: UNAUTHORIZED_MISSION_ID, + userId: SUBJECT_USER_ID, + notes: 'subject note outside grant-visible missions', + createdAt: new Date('2026-06-24T04:00:00.000Z'), + updatedAt: new Date('2026-06-24T04:00:00.000Z'), + }, + ]); +} + +describe('FederationGetQueryService', () => { + beforeAll(async () => { + dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`); + await runPgliteMigrations(dbHandle); + await seedNotesFixture(); + }); + + afterAll(async () => { + await dbHandle?.close(); + dbHandle = undefined; + }); + + it('denies sensitive resources in native RBAC for M3 get reads', async () => { + const service = makeService(); + + await expect( + service.evaluateReadAccess({ + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + resource: 'credentials', + }), + ).resolves.toMatchObject({ + allowed: false, + reason: 'credentials federation get access is not implemented in M3', + }); + }); + + it('allows personal memory reads without requiring team lookup', async () => { + const service = makeService(); + + await expect( + service.evaluateReadAccess({ + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + resource: 'memory', + }), + ).resolves.toEqual({ + allowed: true, + access: { includePersonal: true, teamIds: [] }, + }); + }); + + it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => { + const service = makeService(); + const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']); + ( + service as unknown as { + listSubjectTeamIds: (subjectUserId: string) => Promise; + } + ).listSubjectTeamIds = listSubjectTeamIds; + + await expect( + service.evaluateReadAccess({ + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + resource: 'tasks', + }), + ).resolves.toEqual({ + allowed: true, + access: { includePersonal: true, teamIds: ['team-1', 'team-2'] }, + }); + expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1'); + }); + + it('does not query storage for sensitive get resources even if scope allowed them', async () => { + const service = makeService(); + + await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({ + status: 'denied', + reason: 'credentials federation get is not implemented', + }); + }); + + it('fails closed for unsupported resources instead of returning undefined', async () => { + const service = makeService(); + + await expect( + service.get({ + filter: { + ...CREDENTIAL_FILTER, + resource: 'unknown-resource' as FederationScopeQueryFilter['resource'], + }, + id: 'row-1', + }), + ).resolves.toEqual({ + status: 'denied', + reason: 'Unsupported federation get resource: unknown-resource', + }); + }); + + it('does not leak another user mission task note through team-scoped get reads', async () => { + const service = makeDbService(); + + await expect( + service.get({ + filter: { + resource: 'notes', + subjectUserId: SUBJECT_USER_ID, + includePersonal: false, + teamIds: [TEAM_ID], + limit: 1, + maxRowsPerQuery: 10, + }, + id: OTHER_TEAM_NOTE_ID, + }), + ).resolves.toEqual({ + status: 'denied', + reason: 'Note is outside the federated scope', + }); + }); + + it('does not return subject notes from missions outside the grant-visible project set', async () => { + const service = makeDbService(); + + await expect( + service.get({ + filter: { + resource: 'notes', + subjectUserId: SUBJECT_USER_ID, + includePersonal: true, + teamIds: [TEAM_ID], + limit: 1, + maxRowsPerQuery: 10, + }, + id: SUBJECT_UNAUTHORIZED_NOTE_ID, + }), + ).resolves.toEqual({ + status: 'denied', + reason: 'Note is outside the federated scope', + }); + }); + + it('returns a subject note only when subject ownership and authorized mission intersect', async () => { + const service = makeDbService(); + + await expect( + service.get({ + filter: { + resource: 'notes', + subjectUserId: SUBJECT_USER_ID, + includePersonal: false, + teamIds: [TEAM_ID], + limit: 1, + maxRowsPerQuery: 10, + }, + id: SUBJECT_TEAM_NOTE_ID, + }), + ).resolves.toMatchObject({ + status: 'found', + item: { + id: SUBJECT_TEAM_NOTE_ID, + missionId: TEAM_MISSION_ID, + content: 'subject note on team mission', + }, + }); + }); + + it('does not return subject personal notes when includePersonal is false', async () => { + const service = makeDbService(); + + await expect( + service.get({ + filter: { + resource: 'notes', + subjectUserId: SUBJECT_USER_ID, + includePersonal: false, + teamIds: [TEAM_ID], + limit: 1, + maxRowsPerQuery: 10, + }, + id: SUBJECT_PERSONAL_NOTE_ID, + }), + ).resolves.toEqual({ + status: 'denied', + reason: 'Note is outside the federated scope', + }); + }); +}); diff --git a/apps/gateway/src/federation/server/verbs/__tests__/get.controller.spec.ts b/apps/gateway/src/federation/server/verbs/__tests__/get.controller.spec.ts new file mode 100644 index 0000000..022b59e --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/__tests__/get.controller.spec.ts @@ -0,0 +1,207 @@ +import 'reflect-metadata'; +import { RequestMethod } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FederationAuthGuard } from '../../federation-auth.guard.js'; +import type { + FederationScopeEvaluationResult, + FederationScopeQueryFilter, +} from '../../scope.service.js'; +import { GetController } from '../get.controller.js'; +import type { FederationGetQueryResult } from '../get-query.service.js'; + +const FEDERATION_CONTEXT = { + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + scope: { resources: ['tasks'], max_rows_per_query: 25 }, +}; + +const TASK_FILTER: FederationScopeQueryFilter = { + resource: 'tasks', + subjectUserId: 'user-1', + includePersonal: true, + teamIds: ['team-1'], + limit: 1, + maxRowsPerQuery: 25, +}; + +function makeRequest(): FastifyRequest { + return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest; +} + +function allowedScope( + filter: FederationScopeQueryFilter = TASK_FILTER, +): FederationScopeEvaluationResult { + return { allowed: true, filter }; +} + +function makeController(opts?: { + scopeResult?: FederationScopeEvaluationResult; + queryResult?: FederationGetQueryResult; +}) { + const scope = { + evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()), + }; + const query = { + evaluateReadAccess: vi.fn(), + get: vi.fn().mockResolvedValue( + opts?.queryResult ?? { + status: 'found', + item: { + id: 'task-1', + title: 'Federated task', + createdAt: new Date('2026-06-24T00:00:00.000Z'), + }, + }, + ), + }; + + return { + controller: new GetController(scope as never, query as never), + scope, + query, + }; +} + +describe('GetController', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => { + expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get'); + expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id'); + expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST); + expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]); + }); + + it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => { + const { controller, scope, query } = makeController(); + + const response = await controller.get('tasks', 'task-1', makeRequest()); + + expect(scope.evaluateAccess).toHaveBeenCalledWith({ + context: FEDERATION_CONTEXT, + resource: 'tasks', + requestedLimit: 1, + nativeRbac: query, + }); + expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' }); + expect(response).toEqual({ + item: { + id: 'task-1', + title: 'Federated task', + createdAt: new Date('2026-06-24T00:00:00.000Z'), + _source: 'local', + }, + }); + }); + + it('returns a federation error envelope when auth guard context is missing', async () => { + const { controller, scope, query } = makeController(); + + await expect( + controller.get('tasks', 'task-1', {} as unknown as FastifyRequest), + ).rejects.toMatchObject({ + response: { + error: { + code: 'unauthorized', + message: 'Federation context missing', + }, + }, + status: 401, + }); + expect(scope.evaluateAccess).not.toHaveBeenCalled(); + expect(query.get).not.toHaveBeenCalled(); + }); + + it('returns a federation error envelope when scope evaluation denies access', async () => { + const { controller, query } = makeController({ + scopeResult: { + allowed: false, + deny: { + code: 'resource_excluded', + stage: 'resource_exclusion', + statusCode: 403, + message: 'Requested federation resource is explicitly excluded by grant scope', + grantId: 'grant-1', + peerId: 'peer-1', + subjectUserId: 'user-1', + resource: 'credentials', + }, + }, + }); + + await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({ + response: { + error: { + code: 'scope_violation', + message: 'Requested federation resource is explicitly excluded by grant scope', + }, + }, + status: 403, + }); + expect(query.get).not.toHaveBeenCalled(); + }); + + it('returns 404 when the scoped query layer cannot find the resource id', async () => { + const { controller } = makeController({ queryResult: { status: 'not_found' } }); + + await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({ + response: { error: { code: 'not_found' } }, + status: 404, + }); + }); + + it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => { + const { controller } = makeController({ + queryResult: { status: 'denied', reason: 'Task is outside the federated scope' }, + }); + + await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({ + response: { + error: { + code: 'scope_violation', + message: 'Task is outside the federated scope', + }, + }, + status: 403, + }); + }); + + it('fails closed when the query layer denies an unsupported resource', async () => { + const unsupportedFilter: FederationScopeQueryFilter = { + ...TASK_FILTER, + resource: 'unknown-resource' as FederationScopeQueryFilter['resource'], + }; + const { controller } = makeController({ + scopeResult: allowedScope(unsupportedFilter), + queryResult: { + status: 'denied', + reason: 'Unsupported federation get resource: unknown-resource', + }, + }); + + await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({ + response: { + error: { + code: 'scope_violation', + message: 'Unsupported federation get resource: unknown-resource', + }, + }, + status: 403, + }); + }); + + it('rejects empty ids before evaluating scope', async () => { + const { controller, scope, query } = makeController(); + + await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({ + response: { error: { code: 'invalid_request' } }, + status: 400, + }); + expect(scope.evaluateAccess).not.toHaveBeenCalled(); + expect(query.get).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/federation/server/verbs/get-query.service.ts b/apps/gateway/src/federation/server/verbs/get-query.service.ts new file mode 100644 index 0000000..d439c88 --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/get-query.service.ts @@ -0,0 +1,311 @@ +/** + * Federation get query layer (FED-M3-06). + * + * Read-only DB adapter used by GetController after FederationAuthGuard and + * FederationScopeService have established the subject user, allowed resource, + * native-RBAC intersection, and row cap. Audit writes are intentionally + * deferred to M4. + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { + and, + eq, + inArray, + insights, + or, + missionTasks, + missions, + preferences, + projects, + tasks, + teamMembers, + type Db, +} from '@mosaicstack/db'; +import { DB } from '../../../database/database.module.js'; +import type { + FederationNativeRbacEvaluator, + FederationNativeRbacRequest, + FederationNativeRbacResult, + FederationScopeQueryFilter, +} from '../scope.service.js'; + +export interface FederationGetQueryRequest { + readonly filter: FederationScopeQueryFilter; + readonly id: string; +} + +export interface FederationGetQueryFoundResult> { + readonly status: 'found'; + readonly item: T; +} + +export interface FederationGetQueryNotFoundResult { + readonly status: 'not_found'; +} + +export interface FederationGetQueryDeniedResult { + readonly status: 'denied'; + readonly reason: string; +} + +export type FederationGetQueryResult> = + | FederationGetQueryFoundResult + | FederationGetQueryNotFoundResult + | FederationGetQueryDeniedResult; + +type RowObject = Record; + +function firstRow(rows: T[]): T | undefined { + return rows[0]; +} + +function rowBelongsToAccessibleProjectOrMission( + row: { projectId?: string | null; missionId?: string | null }, + projectIds: readonly string[], + missionIds: readonly string[], +): boolean { + return ( + (typeof row.projectId === 'string' && projectIds.includes(row.projectId)) || + (typeof row.missionId === 'string' && missionIds.includes(row.missionId)) + ); +} + +@Injectable() +export class FederationGetQueryService implements FederationNativeRbacEvaluator { + constructor(@Inject(DB) private readonly db: Db) {} + + async evaluateReadAccess( + request: FederationNativeRbacRequest, + ): Promise { + if (request.resource === 'credentials' || request.resource === 'api_keys') { + return { + allowed: false, + reason: `${request.resource} federation get access is not implemented in M3`, + details: { resource: request.resource }, + }; + } + + if (request.resource === 'memory') { + return { allowed: true, access: { includePersonal: true, teamIds: [] } }; + } + + const teamIds = await this.listSubjectTeamIds(request.subjectUserId); + return { allowed: true, access: { includePersonal: true, teamIds } }; + } + + async get( + request: FederationGetQueryRequest, + ): Promise> { + return this.getByResource(request.filter, request.id) as Promise>; + } + + private async getByResource( + filter: FederationScopeQueryFilter, + id: string, + ): Promise { + switch (filter.resource) { + case 'tasks': + return this.getTask(filter, id); + case 'notes': + return this.getNote(filter, id); + case 'memory': + return this.getMemory(filter, id); + case 'credentials': + case 'api_keys': + return { status: 'denied', reason: `${filter.resource} federation get is not implemented` }; + default: + return { + status: 'denied', + reason: `Unsupported federation get resource: ${String(filter.resource)}`, + }; + } + } + + private async listSubjectTeamIds(subjectUserId: string): Promise { + const rows = await this.db + .select({ teamId: teamMembers.teamId }) + .from(teamMembers) + .where(eq(teamMembers.userId, subjectUserId)); + + return rows.map((row) => row.teamId); + } + + private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise { + const clauses = []; + if (filter.includePersonal) { + clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId))); + } + if (filter.teamIds.length > 0) { + // Project team ownership follows TeamsService.canAccessProject: team-owned + // rows are authorized through projects.teamId, while ownerId remains the + // user who created/bootstrapped the project. + clauses.push( + and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])), + ); + } + + if (clauses.length === 0) { + return []; + } + + const rows = await this.db + .select({ id: projects.id }) + .from(projects) + .where(clauses.length === 1 ? clauses[0] : or(...clauses)); + + return rows.map((row) => row.id); + } + + private async listMissionIds(projectIds: readonly string[]): Promise { + if (projectIds.length === 0) { + return []; + } + + const rows = await this.db + .select({ id: missions.id }) + .from(missions) + .where(inArray(missions.projectId, [...projectIds])); + + return rows.map((row) => row.id); + } + + private async getTask( + filter: FederationScopeQueryFilter, + id: string, + ): Promise { + const row = firstRow( + await this.db + .select({ + id: tasks.id, + title: tasks.title, + description: tasks.description, + status: tasks.status, + priority: tasks.priority, + projectId: tasks.projectId, + missionId: tasks.missionId, + assignee: tasks.assignee, + tags: tasks.tags, + dueDate: tasks.dueDate, + metadata: tasks.metadata, + createdAt: tasks.createdAt, + updatedAt: tasks.updatedAt, + }) + .from(tasks) + .where(eq(tasks.id, id)) + .limit(1), + ); + + if (!row) { + return { status: 'not_found' }; + } + + const projectIds = await this.listAccessibleProjectIds(filter); + const missionIds = await this.listMissionIds(projectIds); + if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) { + return { status: 'denied', reason: 'Task is outside the federated scope' }; + } + + return { status: 'found', item: row as RowObject }; + } + + private async getNote( + filter: FederationScopeQueryFilter, + id: string, + ): Promise { + const row = firstRow( + await this.db + .select({ + id: missionTasks.id, + missionId: missionTasks.missionId, + taskId: missionTasks.taskId, + userId: missionTasks.userId, + status: missionTasks.status, + content: missionTasks.notes, + createdAt: missionTasks.createdAt, + updatedAt: missionTasks.updatedAt, + }) + .from(missionTasks) + .where(eq(missionTasks.id, id)) + .limit(1), + ); + + if (!row || row.content === null || row.content === '') { + return { status: 'not_found' }; + } + + const projectIds = await this.listAccessibleProjectIds(filter); + const missionIds = await this.listMissionIds(projectIds); + + // mission_tasks rows are user-scoped even when the mission belongs to a team. + // Scope-visible missions must intersect with subject ownership; team scope + // narrows mission IDs but never widens note reads to another user's rows. + if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) { + return { status: 'denied', reason: 'Note is outside the federated scope' }; + } + + const item = { ...row } as RowObject; + delete item['userId']; + return { status: 'found', item }; + } + + private async getMemory( + filter: FederationScopeQueryFilter, + id: string, + ): Promise { + const [insightRow, preferenceRow] = await Promise.all([ + this.db + .select({ + id: insights.id, + userId: insights.userId, + kind: insights.source, + content: insights.content, + category: insights.category, + relevanceScore: insights.relevanceScore, + metadata: insights.metadata, + createdAt: insights.createdAt, + updatedAt: insights.updatedAt, + }) + .from(insights) + .where(eq(insights.id, id)) + .limit(1) + .then(firstRow), + this.db + .select({ + id: preferences.id, + userId: preferences.userId, + kind: preferences.category, + key: preferences.key, + value: preferences.value, + source: preferences.source, + mutable: preferences.mutable, + createdAt: preferences.createdAt, + updatedAt: preferences.updatedAt, + }) + .from(preferences) + .where(eq(preferences.id, id)) + .limit(1) + .then(firstRow), + ]); + + const candidates = [insightRow, preferenceRow].filter( + (row): row is NonNullable => row !== undefined, + ); + if (candidates.length === 0) { + return { status: 'not_found' }; + } + + if (!filter.includePersonal) { + return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' }; + } + + const accessible = candidates.find((row) => row.userId === filter.subjectUserId); + if (!accessible) { + return { status: 'denied', reason: 'Memory row belongs to another subject user' }; + } + + const item = { ...accessible } as RowObject; + delete item['userId']; + return { status: 'found', item }; + } +} diff --git a/apps/gateway/src/federation/server/verbs/get.controller.ts b/apps/gateway/src/federation/server/verbs/get.controller.ts new file mode 100644 index 0000000..07e52cb --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/get.controller.ts @@ -0,0 +1,100 @@ +/** + * Federation get verb (FED-M3-06). + * + * POST /api/federation/v1/get/:resource/:id + * + * Pipeline: FederationAuthGuard attaches the active grant context, then + * FederationScopeService enforces grant scope + native RBAC intersection, then + * the read-only query layer fetches one local row and tags it with `_source`. + * Read audit-log writes are deferred to M4; this controller does not persist + * request or response bodies. + */ + +import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import { + FederationInvalidRequestError, + FederationNotFoundError, + FederationScopeViolationError, + FederationUnauthorizedError, + SOURCE_LOCAL, + type FederationGetResponse, + type SourceTag, +} from '@mosaicstack/types'; +import { FederationAuthGuard } from '../federation-auth.guard.js'; +import '../federation-context.js'; +import { FederationScopeService } from '../scope.service.js'; +import { FederationGetQueryService } from './get-query.service.js'; + +type FederatedRow = Record & SourceTag; + +function scopeDenyToHttpException(deny: { + readonly statusCode: 400 | 403; + readonly message: string; +}): HttpException { + const ErrorClass = + deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError; + return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode); +} + +@Controller('api/federation/v1/get') +@UseGuards(FederationAuthGuard) +export class GetController { + constructor( + @Inject(FederationScopeService) private readonly scope: FederationScopeService, + @Inject(FederationGetQueryService) private readonly query: FederationGetQueryService, + ) {} + + @Post(':resource/:id') + async get( + @Param('resource') resource: string, + @Param('id') id: string, + @Req() request: FastifyRequest, + ): Promise> { + if (!request.federationContext) { + throw new HttpException( + new FederationUnauthorizedError('Federation context missing').toEnvelope(), + 401, + ); + } + if (id.trim().length === 0) { + throw new HttpException( + new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(), + 400, + ); + } + + const scopeResult = await this.scope.evaluateAccess({ + context: request.federationContext, + resource, + requestedLimit: 1, + nativeRbac: this.query, + }); + + if (!scopeResult.allowed) { + throw scopeDenyToHttpException(scopeResult.deny); + } + + const result = await this.query.get({ filter: scopeResult.filter, id }); + if (result.status === 'not_found') { + throw new HttpException( + new FederationNotFoundError('Requested federation resource was not found').toEnvelope(), + 404, + ); + } + if (result.status === 'denied') { + throw new HttpException( + new FederationScopeViolationError(result.reason, { + resource, + id, + grantId: request.federationContext.grantId, + peerId: request.federationContext.peerId, + subjectUserId: request.federationContext.subjectUserId, + }).toEnvelope(), + 403, + ); + } + + return { item: { ...result.item, _source: SOURCE_LOCAL } }; + } +} diff --git a/docs/scratchpads/462-fed-m3-06-get-verb.md b/docs/scratchpads/462-fed-m3-06-get-verb.md new file mode 100644 index 0000000..f355565 --- /dev/null +++ b/docs/scratchpads/462-fed-m3-06-get-verb.md @@ -0,0 +1,38 @@ +# Scratchpad — FED-M3-06 get verb + +## Objective + +Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads. + +## Scope + +- `apps/gateway/src/federation/server/verbs/get.controller.ts` +- `apps/gateway/src/federation/server/verbs/get-query.service.ts` +- Unit coverage for controller pipeline + query service RBAC guardrails +- Register controller/service in `FederationModule` + +## Plan + +1. Mirror the list verb pipeline: `FederationAuthGuard` → `FederationScopeService` → read-only query service. +2. Return one `_source: "local"` tagged item on success. +3. Return federation error envelopes: + - `404 not_found` when the resource id does not exist. + - `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection. + - `400 invalid_request` for malformed ids/scope requests. +4. Keep read audit persistence deferred to M4; no body or response persistence in M3. + +## Verification Evidence + +- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services. +- Review-change coverage added for comment 15971: + - get note access now requires subject ownership AND authorized mission intersection. + - missing federation context returns structured `401 unauthorized` envelope. + - unsupported get resources fail closed with structured denial. + - PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion. +- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes). +- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes). +- `pnpm build` — pass (23 successful tasks before review changes). +- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes). +- `pnpm lint` — pass (23 successful tasks; re-run after review changes). +- `pnpm format:check` — pass (re-run after review changes). +- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.