feat(federation): admin controller + CLI federation commands (FED-M2-08) (#498)
This commit was merged in pull request #498.
This commit is contained in:
266
apps/gateway/src/federation/federation.controller.ts
Normal file
266
apps/gateway/src/federation/federation.controller.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* FederationController — admin REST API for federation management (FED-M2-08).
|
||||
*
|
||||
* Routes (all under /api/admin/federation, all require AdminGuard):
|
||||
*
|
||||
* Grant management:
|
||||
* POST /api/admin/federation/grants
|
||||
* GET /api/admin/federation/grants
|
||||
* GET /api/admin/federation/grants/:id
|
||||
* PATCH /api/admin/federation/grants/:id/revoke
|
||||
* POST /api/admin/federation/grants/:id/tokens
|
||||
*
|
||||
* Peer management:
|
||||
* GET /api/admin/federation/peers
|
||||
* POST /api/admin/federation/peers/keypair
|
||||
* PATCH /api/admin/federation/peers/:id/cert
|
||||
*
|
||||
* NOTE: The enrollment REDEMPTION endpoint (POST /api/federation/enrollment/:token)
|
||||
* is handled by EnrollmentController — not duplicated here.
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
||||
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { sealClientKey } from './peer-key.util.js';
|
||||
import { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||
import {
|
||||
CreatePeerKeypairDto,
|
||||
GenerateEnrollmentTokenDto,
|
||||
RevokeGrantBodyDto,
|
||||
StorePeerCertDto,
|
||||
} from './federation-admin.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to a Base64 string (for PEM encoding).
|
||||
*/
|
||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (const b of bytes) {
|
||||
binary += String.fromCharCode(b);
|
||||
}
|
||||
return Buffer.from(binary, 'binary').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Base64 string in PEM armour.
|
||||
*/
|
||||
function toPem(label: string, b64: string): string {
|
||||
const lines = b64.match(/.{1,64}/g) ?? [];
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Controller('api/admin/federation')
|
||||
@UseGuards(AdminGuard)
|
||||
export class FederationController {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(GrantsService) private readonly grantsService: GrantsService,
|
||||
@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService,
|
||||
) {}
|
||||
|
||||
// ─── Grant management ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/grants
|
||||
* Create a new grant in pending state.
|
||||
*/
|
||||
@Post('grants')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createGrant(@Body() body: CreateGrantDto) {
|
||||
return this.grantsService.createGrant(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/grants
|
||||
* List grants with optional filters.
|
||||
*/
|
||||
@Get('grants')
|
||||
async listGrants(@Query() query: ListGrantsDto) {
|
||||
return this.grantsService.listGrants(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/grants/:id
|
||||
* Get a single grant by ID.
|
||||
*/
|
||||
@Get('grants/:id')
|
||||
async getGrant(@Param('id') id: string) {
|
||||
return this.grantsService.getGrant(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/federation/grants/:id/revoke
|
||||
* Revoke an active grant.
|
||||
*/
|
||||
@Patch('grants/:id/revoke')
|
||||
async revokeGrant(@Param('id') id: string, @Body() body: RevokeGrantBodyDto) {
|
||||
return this.grantsService.revokeGrant(id, body.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/grants/:id/tokens
|
||||
* Generate a single-use enrollment token for a pending grant.
|
||||
* Returns the token plus an enrollmentUrl the operator shares out-of-band.
|
||||
*/
|
||||
@Post('grants/:id/tokens')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async generateToken(@Param('id') id: string, @Body() body: GenerateEnrollmentTokenDto) {
|
||||
const grant = await this.grantsService.getGrant(id);
|
||||
|
||||
const result = await this.enrollmentService.createToken({
|
||||
grantId: id,
|
||||
peerId: grant.peerId,
|
||||
ttlSeconds: body.ttlSeconds ?? 900,
|
||||
});
|
||||
|
||||
const baseUrl = process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242';
|
||||
const enrollmentUrl = `${baseUrl}/api/federation/enrollment/${result.token}`;
|
||||
|
||||
return {
|
||||
token: result.token,
|
||||
expiresAt: result.expiresAt,
|
||||
enrollmentUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Peer management ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/peers
|
||||
* List all federation peer rows.
|
||||
*/
|
||||
@Get('peers')
|
||||
async listPeers() {
|
||||
return this.db.select().from(federationPeers).orderBy(federationPeers.commonName);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/peers/keypair
|
||||
* Generate a new peer entry with EC P-256 key pair and a PKCS#10 CSR.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Generate EC P-256 key pair via webcrypto
|
||||
* 2. Generate a self-signed CSR via @peculiar/x509
|
||||
* 3. Export private key as PEM
|
||||
* 4. sealClientKey(privatePem) → sealed blob
|
||||
* 5. Insert pending peer row
|
||||
* 6. Return { peerId, csrPem }
|
||||
*/
|
||||
@Post('peers/keypair')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createPeerKeypair(@Body() body: CreatePeerKeypairDto) {
|
||||
// 1. Generate EC P-256 key pair via Web Crypto
|
||||
const keyPair = await webcrypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true, // extractable
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
// 2. Generate PKCS#10 CSR
|
||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${body.commonName}`,
|
||||
keys: keyPair,
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||
});
|
||||
|
||||
const csrPem = csr.toString('pem');
|
||||
|
||||
// 3. Export private key as PKCS#8 PEM
|
||||
const pkcs8Der = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
const privatePem = toPem('PRIVATE KEY', arrayBufferToBase64(pkcs8Der));
|
||||
|
||||
// 4. Seal the private key
|
||||
const sealed = sealClientKey(privatePem);
|
||||
|
||||
// 5. Insert pending peer row
|
||||
const [peer] = await this.db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
commonName: body.commonName,
|
||||
displayName: body.displayName,
|
||||
certPem: '',
|
||||
certSerial: 'pending',
|
||||
certNotAfter: new Date(0),
|
||||
clientKeyPem: sealed,
|
||||
state: 'pending',
|
||||
endpointUrl: body.endpointUrl,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
peerId: peer!.id,
|
||||
csrPem,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/federation/peers/:id/cert
|
||||
* Store a signed certificate after enrollment completes.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Parse the cert to extract serial and notAfter
|
||||
* 2. Update the peer row with cert data + state='active'
|
||||
* 3. Return the updated peer row
|
||||
*/
|
||||
@Patch('peers/:id/cert')
|
||||
async storePeerCert(@Param('id') id: string, @Body() body: StorePeerCertDto) {
|
||||
// Ensure peer exists
|
||||
const [existing] = await this.db
|
||||
.select({ id: federationPeers.id })
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Peer ${id} not found`);
|
||||
}
|
||||
|
||||
// 1. Parse cert
|
||||
const x509 = new X509Certificate(body.certPem);
|
||||
const certSerial = x509.serialNumber;
|
||||
const certNotAfter = new Date(x509.validTo);
|
||||
|
||||
// 2. Update peer
|
||||
const [updated] = await this.db
|
||||
.update(federationPeers)
|
||||
.set({
|
||||
certPem: body.certPem,
|
||||
certSerial,
|
||||
certNotAfter,
|
||||
state: 'active',
|
||||
})
|
||||
.where(eq(federationPeers.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user