- 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>
162 lines
4.6 KiB
TypeScript
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!;
|
|
}
|
|
}
|