All checks were successful
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 <noreply@anthropic.com>
101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
/**
|
|
* 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<string, unknown> & 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<FederationGetResponse<FederatedRow>> {
|
|
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 } };
|
|
}
|
|
}
|