Files
stack/apps/gateway/src/federation/grants.service.ts
Jarvis 0d9c1ce2e7
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
feat(federation): grants service CRUD + status transitions (FED-M2-06)
- Add 'pending' to grantStatusEnum (pending → active → revoked/expired)
- Update federationGrants default status to 'pending'
- Add migration 0009_federation_grant_pending.sql
- GrantsService: createGrant, getGrant, listGrants, activateGrant, revokeGrant, expireGrant
- Invalid transitions throw ConflictException; missing grants throw NotFoundException
- CreateGrantDto validates scope via parseFederationScope before insert
- Full unit test coverage for all status transitions and edge cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:51:05 -05:00

162 lines
4.6 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 } 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;
@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;
}
/**
* 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!;
}
}