Adds FederationAuthGuard that validates inbound mTLS client certs on federation API routes. Extracts custom OIDs (grantId, subjectUserId), loads the grant+peer from DB in one query, asserts active status, and validates cert serial as defense-in-depth. Attaches FederationContext to requests on success and uses federation wire-format error envelopes (not raw NestJS exceptions) for 401/403 responses. New files: - apps/gateway/src/federation/oid.util.ts — shared OID extraction (no dupe ASN.1 logic) - apps/gateway/src/federation/server/federation-auth.guard.ts — guard impl - apps/gateway/src/federation/server/federation-context.ts — FederationContext type + module augment - apps/gateway/src/federation/server/index.ts — barrel export - apps/gateway/src/federation/server/__tests__/federation-auth.guard.spec.ts — 11 unit tests Modified: - apps/gateway/src/federation/grants.service.ts — adds getGrantWithPeer() with join - apps/gateway/src/federation/federation.module.ts — registers FederationAuthGuard as provider Closes #462 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
191 lines
5.5 KiB
TypeScript
191 lines
5.5 KiB
TypeScript
/**
|
|
* Federation grants service — CRUD + status transitions (FED-M2-06).
|
|
*
|
|
* Business logic only. CSR/cert work is handled by M2-07.
|
|
*
|
|
* Status lifecycle:
|
|
* pending → active (activateGrant, called by M2-07 enrollment controller after cert signed)
|
|
* active → revoked (revokeGrant)
|
|
* active → expired (expireGrant, called by M6 scheduler)
|
|
*/
|
|
|
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { type Db, and, eq, federationGrants, federationPeers } from '@mosaicstack/db';
|
|
import { DB } from '../database/database.module.js';
|
|
import { parseFederationScope } from './scope-schema.js';
|
|
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
|
|
|
export type Grant = typeof federationGrants.$inferSelect;
|
|
export type Peer = typeof federationPeers.$inferSelect;
|
|
export type GrantWithPeer = Grant & { peer: Peer };
|
|
|
|
@Injectable()
|
|
export class GrantsService {
|
|
constructor(@Inject(DB) private readonly db: Db) {}
|
|
|
|
/**
|
|
* Create a new grant in `pending` state.
|
|
* Validates the scope against the federation scope JSON schema before inserting.
|
|
*/
|
|
async createGrant(dto: CreateGrantDto): Promise<Grant> {
|
|
// Throws FederationScopeError (a plain Error subclass) on invalid scope.
|
|
parseFederationScope(dto.scope);
|
|
|
|
const [grant] = await this.db
|
|
.insert(federationGrants)
|
|
.values({
|
|
peerId: dto.peerId,
|
|
subjectUserId: dto.subjectUserId,
|
|
scope: dto.scope,
|
|
status: 'pending',
|
|
expiresAt: dto.expiresAt != null ? new Date(dto.expiresAt) : null,
|
|
})
|
|
.returning();
|
|
|
|
return grant!;
|
|
}
|
|
|
|
/**
|
|
* Fetch a single grant by ID. Throws NotFoundException if not found.
|
|
*/
|
|
async getGrant(id: string): Promise<Grant> {
|
|
const [grant] = await this.db
|
|
.select()
|
|
.from(federationGrants)
|
|
.where(eq(federationGrants.id, id))
|
|
.limit(1);
|
|
|
|
if (!grant) {
|
|
throw new NotFoundException(`Grant ${id} not found`);
|
|
}
|
|
|
|
return grant;
|
|
}
|
|
|
|
/**
|
|
* Fetch a single grant by ID, joined with its associated peer row.
|
|
* Used by FederationAuthGuard to perform grant status + cert serial checks
|
|
* in a single DB round-trip.
|
|
*
|
|
* Throws NotFoundException if the grant does not exist.
|
|
* Throws NotFoundException if the associated peer row is missing (data integrity issue).
|
|
*/
|
|
async getGrantWithPeer(id: string): Promise<GrantWithPeer> {
|
|
const rows = await this.db
|
|
.select()
|
|
.from(federationGrants)
|
|
.innerJoin(federationPeers, eq(federationGrants.peerId, federationPeers.id))
|
|
.where(eq(federationGrants.id, id))
|
|
.limit(1);
|
|
|
|
const row = rows[0];
|
|
if (!row) {
|
|
throw new NotFoundException(`Grant ${id} not found`);
|
|
}
|
|
|
|
return {
|
|
...row.federation_grants,
|
|
peer: row.federation_peers,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List grants with optional filters for peerId, subjectUserId, and status.
|
|
*/
|
|
async listGrants(filters: ListGrantsDto): Promise<Grant[]> {
|
|
const conditions = [];
|
|
|
|
if (filters.peerId != null) {
|
|
conditions.push(eq(federationGrants.peerId, filters.peerId));
|
|
}
|
|
if (filters.subjectUserId != null) {
|
|
conditions.push(eq(federationGrants.subjectUserId, filters.subjectUserId));
|
|
}
|
|
if (filters.status != null) {
|
|
conditions.push(eq(federationGrants.status, filters.status));
|
|
}
|
|
|
|
if (conditions.length === 0) {
|
|
return this.db.select().from(federationGrants);
|
|
}
|
|
|
|
return this.db
|
|
.select()
|
|
.from(federationGrants)
|
|
.where(and(...conditions));
|
|
}
|
|
|
|
/**
|
|
* Transition a grant from `pending` → `active`.
|
|
* Called by M2-07 enrollment controller after cert is signed.
|
|
* Throws ConflictException if the grant is not in `pending` state.
|
|
*/
|
|
async activateGrant(id: string): Promise<Grant> {
|
|
const grant = await this.getGrant(id);
|
|
|
|
if (grant.status !== 'pending') {
|
|
throw new ConflictException(
|
|
`Grant ${id} cannot be activated: expected status 'pending', got '${grant.status}'`,
|
|
);
|
|
}
|
|
|
|
const [updated] = await this.db
|
|
.update(federationGrants)
|
|
.set({ status: 'active' })
|
|
.where(eq(federationGrants.id, id))
|
|
.returning();
|
|
|
|
return updated!;
|
|
}
|
|
|
|
/**
|
|
* Transition a grant from `active` → `revoked`.
|
|
* Sets revokedAt and optionally revokedReason.
|
|
* Throws ConflictException if the grant is not in `active` state.
|
|
*/
|
|
async revokeGrant(id: string, reason?: string): Promise<Grant> {
|
|
const grant = await this.getGrant(id);
|
|
|
|
if (grant.status !== 'active') {
|
|
throw new ConflictException(
|
|
`Grant ${id} cannot be revoked: expected status 'active', got '${grant.status}'`,
|
|
);
|
|
}
|
|
|
|
const [updated] = await this.db
|
|
.update(federationGrants)
|
|
.set({
|
|
status: 'revoked',
|
|
revokedAt: new Date(),
|
|
revokedReason: reason ?? null,
|
|
})
|
|
.where(eq(federationGrants.id, id))
|
|
.returning();
|
|
|
|
return updated!;
|
|
}
|
|
|
|
/**
|
|
* Transition a grant from `active` → `expired`.
|
|
* Intended for use by the M6 scheduler.
|
|
* Throws ConflictException if the grant is not in `active` state.
|
|
*/
|
|
async expireGrant(id: string): Promise<Grant> {
|
|
const grant = await this.getGrant(id);
|
|
|
|
if (grant.status !== 'active') {
|
|
throw new ConflictException(
|
|
`Grant ${id} cannot be expired: expected status 'active', got '${grant.status}'`,
|
|
);
|
|
}
|
|
|
|
const [updated] = await this.db
|
|
.update(federationGrants)
|
|
.set({ status: 'expired' })
|
|
.where(eq(federationGrants.id, id))
|
|
.returning();
|
|
|
|
return updated!;
|
|
}
|
|
}
|