feat(federation): grants service CRUD + status transitions (FED-M2-06) (#496)
This commit was merged in pull request #496.
This commit is contained in:
161
apps/gateway/src/federation/grants.service.ts
Normal file
161
apps/gateway/src/federation/grants.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user