/** * 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 }; } }