feat(federation): enrollment controller + single-use token flow (FED-M2-07) (#497)
This commit was merged in pull request #497.
This commit is contained in:
230
apps/gateway/src/federation/enrollment.service.ts
Normal file
230
apps/gateway/src/federation/enrollment.service.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* EnrollmentService — single-use enrollment token lifecycle (FED-M2-07).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Generate time-limited single-use enrollment tokens (admin action).
|
||||
* 2. Redeem a token: validate → atomically claim token → issue cert via
|
||||
* CaService → transactionally activate grant + update peer + write audit.
|
||||
*
|
||||
* Replay protection: the token is claimed (UPDATE WHERE used_at IS NULL) BEFORE
|
||||
* cert issuance. This prevents double cert minting on concurrent requests.
|
||||
* If cert issuance fails after claim, the token is consumed and the grant
|
||||
* stays pending — admin must create a new grant.
|
||||
*/
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
GoneException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
// X509Certificate is available as a named export in Node.js ≥ 15.6
|
||||
const { X509Certificate } = crypto;
|
||||
import {
|
||||
type Db,
|
||||
and,
|
||||
eq,
|
||||
isNull,
|
||||
sql,
|
||||
federationEnrollmentTokens,
|
||||
federationGrants,
|
||||
federationPeers,
|
||||
federationAuditLog,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { CaService } from './ca.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationScopeError } from './scope-schema.js';
|
||||
import type { CreateEnrollmentTokenDto } from './enrollment.dto.js';
|
||||
|
||||
export interface EnrollmentTokenResult {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface RedeemResult {
|
||||
certPem: string;
|
||||
certChainPem: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentService {
|
||||
private readonly logger = new Logger(EnrollmentService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly caService: CaService,
|
||||
private readonly grantsService: GrantsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a single-use enrollment token for an admin to distribute
|
||||
* out-of-band to the remote peer operator.
|
||||
*/
|
||||
async createToken(dto: CreateEnrollmentTokenDto): Promise<EnrollmentTokenResult> {
|
||||
const ttl = Math.min(dto.ttlSeconds, 900);
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
|
||||
await this.db.insert(federationEnrollmentTokens).values({
|
||||
token,
|
||||
grantId: dto.grantId,
|
||||
peerId: dto.peerId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Enrollment token created — grantId=${dto.grantId} peerId=${dto.peerId} expiresAt=${expiresAt.toISOString()}`,
|
||||
);
|
||||
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem an enrollment token.
|
||||
*
|
||||
* Full flow:
|
||||
* 1. Fetch token row — NotFoundException if not found
|
||||
* 2. usedAt set → GoneException (already used)
|
||||
* 3. expiresAt < now → GoneException (expired)
|
||||
* 4. Load grant — verify status is 'pending'
|
||||
* 5. Atomically claim token (UPDATE WHERE used_at IS NULL RETURNING token)
|
||||
* — if no rows returned, concurrent request won → GoneException
|
||||
* 6. Issue cert via CaService (network call, outside transaction)
|
||||
* — if this fails, token is consumed; grant stays pending; admin must recreate
|
||||
* 7. Transaction: activate grant + update peer record + write audit log
|
||||
* 8. Return { certPem, certChainPem }
|
||||
*/
|
||||
async redeem(token: string, csrPem: string): Promise<RedeemResult> {
|
||||
// 1. Fetch token row
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(federationEnrollmentTokens)
|
||||
.where(eq(federationEnrollmentTokens.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new NotFoundException('Enrollment token not found');
|
||||
}
|
||||
|
||||
// 2. Already used?
|
||||
if (row.usedAt !== null) {
|
||||
throw new GoneException('Enrollment token has already been used');
|
||||
}
|
||||
|
||||
// 3. Expired?
|
||||
if (row.expiresAt < new Date()) {
|
||||
throw new GoneException('Enrollment token has expired');
|
||||
}
|
||||
|
||||
// 4. Load grant and verify it is still pending
|
||||
let grant;
|
||||
try {
|
||||
grant = await this.grantsService.getGrant(row.grantId);
|
||||
} catch (err) {
|
||||
if (err instanceof FederationScopeError) {
|
||||
throw new BadRequestException(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (grant.status !== 'pending') {
|
||||
throw new GoneException(
|
||||
`Grant ${row.grantId} is no longer pending (status: ${grant.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Atomically claim the token BEFORE cert issuance to prevent double-minting.
|
||||
// WHERE used_at IS NULL ensures only one concurrent request wins.
|
||||
// Using .returning() works on both node-postgres and PGlite without rowCount inspection.
|
||||
const claimed = await this.db
|
||||
.update(federationEnrollmentTokens)
|
||||
.set({ usedAt: sql`NOW()` })
|
||||
.where(
|
||||
and(eq(federationEnrollmentTokens.token, token), isNull(federationEnrollmentTokens.usedAt)),
|
||||
)
|
||||
.returning({ token: federationEnrollmentTokens.token });
|
||||
|
||||
if (claimed.length === 0) {
|
||||
throw new GoneException('Enrollment token has already been used (concurrent request)');
|
||||
}
|
||||
|
||||
// 6. Issue certificate via CaService (network call — outside any transaction).
|
||||
// If this throws, the token is already consumed. The grant stays pending.
|
||||
// Admin must revoke the grant and create a new one.
|
||||
let issued;
|
||||
try {
|
||||
issued = await this.caService.issueCert({
|
||||
csrPem,
|
||||
grantId: row.grantId,
|
||||
subjectUserId: grant.subjectUserId,
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`issueCert failed after token ${token} was claimed — grant ${row.grantId} is stranded pending`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
if (err instanceof FederationScopeError) {
|
||||
throw new BadRequestException((err as Error).message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 7. Atomically activate grant, update peer record, and write audit log.
|
||||
const certNotAfter = this.extractCertNotAfter(issued.certPem);
|
||||
await this.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(federationGrants)
|
||||
.set({ status: 'active' })
|
||||
.where(eq(federationGrants.id, row.grantId));
|
||||
|
||||
await tx
|
||||
.update(federationPeers)
|
||||
.set({
|
||||
certPem: issued.certPem,
|
||||
certSerial: issued.serialNumber,
|
||||
certNotAfter,
|
||||
state: 'active',
|
||||
})
|
||||
.where(eq(federationPeers.id, row.peerId));
|
||||
|
||||
await tx.insert(federationAuditLog).values({
|
||||
requestId: crypto.randomUUID(),
|
||||
peerId: row.peerId,
|
||||
grantId: row.grantId,
|
||||
verb: 'enrollment',
|
||||
resource: 'federation_grant',
|
||||
statusCode: 200,
|
||||
outcome: 'allowed',
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Enrollment complete — peerId=${row.peerId} grantId=${row.grantId} serial=${issued.serialNumber}`,
|
||||
);
|
||||
|
||||
// 8. Return cert material
|
||||
return {
|
||||
certPem: issued.certPem,
|
||||
certChainPem: issued.certChainPem,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the notAfter date from a PEM certificate.
|
||||
* Falls back to 90 days from now if parsing fails.
|
||||
*/
|
||||
private extractCertNotAfter(certPem: string): Date {
|
||||
try {
|
||||
const cert = new X509Certificate(certPem);
|
||||
return new Date(cert.validTo);
|
||||
} catch {
|
||||
// Fallback: 90 days from now
|
||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user