ci/woodpecker/push/ci Pipeline was successful
FED-M3-06 get verb. Trust boundary mirrors M3-05 AND-intersect (note returned only when owned by subject AND on an authorized mission). Reviewed (review-of-record APPROVE, head 80a259b2) + green PR-event CI 1620.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
312 lines
9.3 KiB
TypeScript
312 lines
9.3 KiB
TypeScript
/**
|
|
* 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<T extends object = Record<string, unknown>> {
|
|
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<T extends object = Record<string, unknown>> =
|
|
| FederationGetQueryFoundResult<T>
|
|
| FederationGetQueryNotFoundResult
|
|
| FederationGetQueryDeniedResult;
|
|
|
|
type RowObject = Record<string, unknown>;
|
|
|
|
function firstRow<T>(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<FederationNativeRbacResult> {
|
|
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<T extends RowObject = RowObject>(
|
|
request: FederationGetQueryRequest,
|
|
): Promise<FederationGetQueryResult<T>> {
|
|
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
|
}
|
|
|
|
private async getByResource(
|
|
filter: FederationScopeQueryFilter,
|
|
id: string,
|
|
): Promise<FederationGetQueryResult> {
|
|
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<string[]> {
|
|
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<string[]> {
|
|
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<string[]> {
|
|
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<FederationGetQueryResult> {
|
|
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<FederationGetQueryResult> {
|
|
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<FederationGetQueryResult> {
|
|
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<typeof row> => 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 };
|
|
}
|
|
}
|