feat(federation): admin controller + CLI federation commands (FED-M2-08) (#498)
This commit was merged in pull request #498.
This commit is contained in:
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationController (FED-M2-08).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - listGrants: delegates to GrantsService with query params
|
||||||
|
* - createGrant: delegates to GrantsService, validates body
|
||||||
|
* - generateToken: returns enrollmentUrl containing the token
|
||||||
|
* - listPeers: returns DB rows
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import type { Db } from '@mosaicstack/db';
|
||||||
|
import { FederationController } from '../federation.controller.js';
|
||||||
|
import type { GrantsService } from '../grants.service.js';
|
||||||
|
import type { EnrollmentService } from '../enrollment.service.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
|
||||||
|
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
|
||||||
|
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
|
||||||
|
|
||||||
|
const MOCK_GRANT = {
|
||||||
|
id: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: { resources: ['tasks'], operations: ['list'] },
|
||||||
|
status: 'pending' as const,
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
revokedReason: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_PEER = {
|
||||||
|
id: PEER_ID,
|
||||||
|
commonName: 'test-peer',
|
||||||
|
displayName: 'Test Peer',
|
||||||
|
certPem: '',
|
||||||
|
certSerial: 'pending',
|
||||||
|
certNotAfter: new Date(0),
|
||||||
|
clientKeyPem: null,
|
||||||
|
state: 'pending' as const,
|
||||||
|
endpointUrl: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB mock builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeDbMock(rows: unknown[] = []) {
|
||||||
|
const orderBy = vi.fn().mockResolvedValue(rows);
|
||||||
|
const where = vi.fn().mockReturnValue({ orderBy });
|
||||||
|
const from = vi.fn().mockReturnValue({ where, orderBy });
|
||||||
|
const select = vi.fn().mockReturnValue({ from });
|
||||||
|
|
||||||
|
return {
|
||||||
|
select,
|
||||||
|
from,
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
} as unknown as Db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FederationController', () => {
|
||||||
|
let db: Db;
|
||||||
|
let grantsService: GrantsService;
|
||||||
|
let enrollmentService: EnrollmentService;
|
||||||
|
let controller: FederationController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = makeDbMock([MOCK_PEER]);
|
||||||
|
|
||||||
|
grantsService = {
|
||||||
|
createGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
||||||
|
getGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
||||||
|
listGrants: vi.fn().mockResolvedValue([MOCK_GRANT]),
|
||||||
|
revokeGrant: vi.fn().mockResolvedValue({ ...MOCK_GRANT, status: 'revoked' }),
|
||||||
|
activateGrant: vi.fn(),
|
||||||
|
expireGrant: vi.fn(),
|
||||||
|
} as unknown as GrantsService;
|
||||||
|
|
||||||
|
enrollmentService = {
|
||||||
|
createToken: vi.fn().mockResolvedValue({
|
||||||
|
token: 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12',
|
||||||
|
expiresAt: '2026-01-01T00:15:00.000Z',
|
||||||
|
}),
|
||||||
|
redeem: vi.fn(),
|
||||||
|
} as unknown as EnrollmentService;
|
||||||
|
|
||||||
|
controller = new FederationController(db, grantsService, enrollmentService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Grant management ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listGrants', () => {
|
||||||
|
it('delegates to GrantsService with provided query params', async () => {
|
||||||
|
const query = { peerId: PEER_ID, status: 'pending' as const };
|
||||||
|
const result = await controller.listGrants(query);
|
||||||
|
|
||||||
|
expect(grantsService.listGrants).toHaveBeenCalledWith(query);
|
||||||
|
expect(result).toEqual([MOCK_GRANT]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates to GrantsService with empty filters', async () => {
|
||||||
|
const result = await controller.listGrants({});
|
||||||
|
|
||||||
|
expect(grantsService.listGrants).toHaveBeenCalledWith({});
|
||||||
|
expect(result).toEqual([MOCK_GRANT]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGrant', () => {
|
||||||
|
it('delegates to GrantsService and returns created grant', async () => {
|
||||||
|
const body = {
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: { resources: ['tasks'], operations: ['list'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await controller.createGrant(body);
|
||||||
|
|
||||||
|
expect(grantsService.createGrant).toHaveBeenCalledWith(body);
|
||||||
|
expect(result).toEqual(MOCK_GRANT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGrant', () => {
|
||||||
|
it('delegates to GrantsService with provided ID', async () => {
|
||||||
|
const result = await controller.getGrant(GRANT_ID);
|
||||||
|
|
||||||
|
expect(grantsService.getGrant).toHaveBeenCalledWith(GRANT_ID);
|
||||||
|
expect(result).toEqual(MOCK_GRANT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revokeGrant', () => {
|
||||||
|
it('delegates to GrantsService with id and reason', async () => {
|
||||||
|
const result = await controller.revokeGrant(GRANT_ID, { reason: 'test reason' });
|
||||||
|
|
||||||
|
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, 'test reason');
|
||||||
|
expect(result).toMatchObject({ status: 'revoked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates without reason when omitted', async () => {
|
||||||
|
await controller.revokeGrant(GRANT_ID, {});
|
||||||
|
|
||||||
|
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateToken', () => {
|
||||||
|
it('returns enrollmentUrl containing the token', async () => {
|
||||||
|
const token = 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12';
|
||||||
|
vi.mocked(enrollmentService.createToken).mockResolvedValueOnce({
|
||||||
|
token,
|
||||||
|
expiresAt: '2026-01-01T00:15:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.generateToken(GRANT_ID, { ttlSeconds: 900 });
|
||||||
|
|
||||||
|
expect(result.token).toBe(token);
|
||||||
|
expect(result.enrollmentUrl).toContain(token);
|
||||||
|
expect(result.enrollmentUrl).toContain('/api/federation/enrollment/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates token via EnrollmentService with correct grantId and peerId', async () => {
|
||||||
|
await controller.generateToken(GRANT_ID, { ttlSeconds: 300 });
|
||||||
|
|
||||||
|
expect(enrollmentService.createToken).toHaveBeenCalledWith({
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
ttlSeconds: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when grant does not exist', async () => {
|
||||||
|
vi.mocked(grantsService.getGrant).mockRejectedValueOnce(
|
||||||
|
new NotFoundException(`Grant ${GRANT_ID} not found`),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(controller.generateToken(GRANT_ID, { ttlSeconds: 900 })).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Peer management ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listPeers', () => {
|
||||||
|
it('returns DB rows ordered by commonName', async () => {
|
||||||
|
const result = await controller.listPeers();
|
||||||
|
|
||||||
|
expect(db.select).toHaveBeenCalled();
|
||||||
|
// The DB mock resolves with [MOCK_PEER]
|
||||||
|
expect(result).toEqual([MOCK_PEER]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/gateway/src/federation/federation-admin.dto.ts
Normal file
39
apps/gateway/src/federation/federation-admin.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* DTOs for the federation admin controller (FED-M2-08).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePeerKeypairDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
commonName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
endpointUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorePeerCertDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
certPem!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerateEnrollmentTokenDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(60)
|
||||||
|
@Max(900)
|
||||||
|
ttlSeconds: number = 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RevokeGrantBodyDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@ import { AdminGuard } from '../admin/admin.guard.js';
|
|||||||
import { CaService } from './ca.service.js';
|
import { CaService } from './ca.service.js';
|
||||||
import { EnrollmentController } from './enrollment.controller.js';
|
import { EnrollmentController } from './enrollment.controller.js';
|
||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
|
import { FederationController } from './federation.controller.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController],
|
controllers: [EnrollmentController, FederationController],
|
||||||
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
||||||
exports: [CaService, EnrollmentService, GrantsService],
|
exports: [CaService, EnrollmentService, GrantsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { registerUninstallCommand } from './commands/uninstall.js';
|
|||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
|
import { registerFederationCommand } from './commands/federation.js';
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
import {
|
import {
|
||||||
backgroundUpdateCheck,
|
backgroundUpdateCheck,
|
||||||
@@ -336,6 +337,10 @@ registerAuthCommand(program);
|
|||||||
|
|
||||||
registerGatewayCommand(program);
|
registerGatewayCommand(program);
|
||||||
|
|
||||||
|
// ─── federation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerFederationCommand(program);
|
||||||
|
|
||||||
// ─── agent ─────────────────────────────────────────────────────────────
|
// ─── agent ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerAgentCommand(program);
|
registerAgentCommand(program);
|
||||||
|
|||||||
410
packages/mosaic/src/commands/federation.ts
Normal file
410
packages/mosaic/src/commands/federation.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* `mosaic federation` command group — federation grant + peer management (FED-M2-08).
|
||||||
|
*
|
||||||
|
* All HTTP calls go to the local gateway admin API using an admin token
|
||||||
|
* resolved from CLI options or meta.json.
|
||||||
|
*
|
||||||
|
* Subcommands:
|
||||||
|
* grant create --peer-id <uuid> --user-id <uuid> --scope <json> [--expires-at <iso>]
|
||||||
|
* grant list [--peer-id <uuid>] [--user-id <uuid>] [--status pending|active|revoked|expired]
|
||||||
|
* grant show <id>
|
||||||
|
* grant revoke <id> [--reason <text>]
|
||||||
|
* grant token <id> [--ttl 900]
|
||||||
|
*
|
||||||
|
* peer list
|
||||||
|
* peer add <enrollment-url>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import { readMeta } from './gateway/daemon.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FedParentOpts {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
token?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedOpts {
|
||||||
|
baseUrl: string;
|
||||||
|
token?: string;
|
||||||
|
json: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveOpts(raw: FedParentOpts): ResolvedOpts {
|
||||||
|
const meta = readMeta();
|
||||||
|
const host = raw.host ?? meta?.host ?? 'localhost';
|
||||||
|
const port = parseInt(raw.port, 10) || meta?.port || 14242;
|
||||||
|
const token = raw.token ?? meta?.adminToken;
|
||||||
|
return {
|
||||||
|
baseUrl: `http://${host}:${port.toString()}`,
|
||||||
|
token,
|
||||||
|
json: raw.json ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireToken(opts: ResolvedOpts): string {
|
||||||
|
if (!opts.token) {
|
||||||
|
console.error(
|
||||||
|
'Error: admin token required. Use -t/--token <token> or ensure meta.json has adminToken.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return opts.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest<T>(
|
||||||
|
opts: ResolvedOpts,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = requireToken(opts);
|
||||||
|
const url = `${opts.baseUrl}${path}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = text;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { message?: string };
|
||||||
|
message = parsed.message ?? text;
|
||||||
|
} catch {
|
||||||
|
// use raw text
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${res.status.toString()}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return undefined as unknown as T;
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printJson(data: unknown, useJson: boolean): void {
|
||||||
|
if (useJson) {
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTable(rows: Record<string, unknown>[]): void {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log('(none)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const [key, val] of Object.entries(row)) {
|
||||||
|
console.log(` ${key}: ${String(val ?? '')}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function registerFederationCommand(program: Command): void {
|
||||||
|
const fed = program
|
||||||
|
.command('federation')
|
||||||
|
.alias('fed')
|
||||||
|
.description('Manage federation grants and peers')
|
||||||
|
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
||||||
|
.option('-p, --port <port>', 'Gateway port', '14242')
|
||||||
|
.option('-t, --token <token>', 'Admin token')
|
||||||
|
.option('--json', 'Machine-readable JSON output')
|
||||||
|
.action(() => fed.outputHelp());
|
||||||
|
|
||||||
|
// ─── grant subcommands ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const grant = fed
|
||||||
|
.command('grant')
|
||||||
|
.description('Manage federation grants')
|
||||||
|
.action(() => grant.outputHelp());
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new federation grant')
|
||||||
|
.requiredOption('--peer-id <uuid>', 'Peer UUID')
|
||||||
|
.requiredOption('--user-id <uuid>', 'Subject user UUID')
|
||||||
|
.requiredOption('--scope <json>', 'Grant scope as JSON string')
|
||||||
|
.option('--expires-at <iso>', 'Optional expiry (ISO 8601)')
|
||||||
|
.action(
|
||||||
|
async (cmdOpts: { peerId: string; userId: string; scope: string; expiresAt?: string }) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
let scope: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
scope = JSON.parse(cmdOpts.scope) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
console.error('Error: --scope must be valid JSON');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
peerId: cmdOpts.peerId,
|
||||||
|
subjectUserId: cmdOpts.userId,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
if (cmdOpts.expiresAt) body['expiresAt'] = cmdOpts.expiresAt;
|
||||||
|
|
||||||
|
const result = await apiRequest<Record<string, unknown>>(
|
||||||
|
opts,
|
||||||
|
'POST',
|
||||||
|
'/api/admin/federation/grants',
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log(`Grant created: ${String(result['id'])}`);
|
||||||
|
console.log(` Peer: ${String(result['peerId'])}`);
|
||||||
|
console.log(` User: ${String(result['subjectUserId'])}`);
|
||||||
|
console.log(` Status: ${String(result['status'])}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('list')
|
||||||
|
.description('List federation grants')
|
||||||
|
.option('--peer-id <uuid>', 'Filter by peer UUID')
|
||||||
|
.option('--user-id <uuid>', 'Filter by subject user UUID')
|
||||||
|
.option('--status <status>', 'Filter by status (pending|active|revoked|expired)')
|
||||||
|
.action(async (cmdOpts: { peerId?: string; userId?: string; status?: string }) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (cmdOpts.peerId) params.set('peerId', cmdOpts.peerId);
|
||||||
|
if (cmdOpts.userId) params.set('subjectUserId', cmdOpts.userId);
|
||||||
|
if (cmdOpts.status) params.set('status', cmdOpts.status);
|
||||||
|
|
||||||
|
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||||
|
const result = await apiRequest<Record<string, unknown>[]>(
|
||||||
|
opts,
|
||||||
|
'GET',
|
||||||
|
`/api/admin/federation/grants${qs}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log(`Grants (${result.length.toString()}):\n`);
|
||||||
|
printTable(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('show <id>')
|
||||||
|
.description('Get a single grant by ID')
|
||||||
|
.action(async (id: string) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<Record<string, unknown>>(
|
||||||
|
opts,
|
||||||
|
'GET',
|
||||||
|
`/api/admin/federation/grants/${id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
for (const [key, val] of Object.entries(result)) {
|
||||||
|
console.log(` ${key}: ${String(val ?? '')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('revoke <id>')
|
||||||
|
.description('Revoke an active grant')
|
||||||
|
.option('--reason <text>', 'Revocation reason')
|
||||||
|
.action(async (id: string, cmdOpts: { reason?: string }) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (cmdOpts.reason) body['reason'] = cmdOpts.reason;
|
||||||
|
|
||||||
|
const result = await apiRequest<Record<string, unknown>>(
|
||||||
|
opts,
|
||||||
|
'PATCH',
|
||||||
|
`/api/admin/federation/grants/${id}/revoke`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log(`Grant ${id} revoked.`);
|
||||||
|
if (result['revokedReason']) console.log(` Reason: ${String(result['revokedReason'])}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('token <id>')
|
||||||
|
.description('Generate a single-use enrollment token for a grant')
|
||||||
|
.option('--ttl <seconds>', 'Token lifetime in seconds (60-900)', '900')
|
||||||
|
.action(async (id: string, cmdOpts: { ttl: string }) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const ttlSeconds = parseInt(cmdOpts.ttl, 10) || 900;
|
||||||
|
const result = await apiRequest<{
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
enrollmentUrl: string;
|
||||||
|
}>(opts, 'POST', `/api/admin/federation/grants/${id}/tokens`, { ttlSeconds });
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log('Enrollment token generated:');
|
||||||
|
console.log(` Token: ${result.token}`);
|
||||||
|
console.log(` Expires at: ${result.expiresAt}`);
|
||||||
|
console.log(` Enrollment URL: ${result.enrollmentUrl}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('Share the enrollment URL with the remote peer operator.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── peer subcommands ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const peer = fed
|
||||||
|
.command('peer')
|
||||||
|
.description('Manage federation peers')
|
||||||
|
.action(() => peer.outputHelp());
|
||||||
|
|
||||||
|
peer
|
||||||
|
.command('list')
|
||||||
|
.description('List all federation peers')
|
||||||
|
.action(async () => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<Record<string, unknown>[]>(
|
||||||
|
opts,
|
||||||
|
'GET',
|
||||||
|
'/api/admin/federation/peers',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log(`Peers (${result.length.toString()}):\n`);
|
||||||
|
printTable(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
peer
|
||||||
|
.command('add <enrollment-url>')
|
||||||
|
.description('Enroll as a peer using a remote enrollment URL')
|
||||||
|
.action(async (enrollmentUrl: string) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
// 1. Validate enrollment URL
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(enrollmentUrl);
|
||||||
|
} catch {
|
||||||
|
console.error(`Error: invalid enrollment URL: ${enrollmentUrl}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
||||||
|
console.error('Error: enrollment URL must use http or https');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsedUrl.hostname;
|
||||||
|
const commonName = hostname.replace(/\./g, '-');
|
||||||
|
|
||||||
|
console.log(`Enrolling as peer with remote: ${enrollmentUrl}`);
|
||||||
|
console.log(` Common name: ${commonName}`);
|
||||||
|
|
||||||
|
// 2. Generate key pair and CSR via local gateway
|
||||||
|
console.log('Generating key pair and CSR...');
|
||||||
|
const keypairResult = await apiRequest<{ peerId: string; csrPem: string }>(
|
||||||
|
opts,
|
||||||
|
'POST',
|
||||||
|
'/api/admin/federation/peers/keypair',
|
||||||
|
{ commonName, displayName: hostname },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { peerId, csrPem } = keypairResult;
|
||||||
|
console.log(` Peer ID: ${peerId}`);
|
||||||
|
|
||||||
|
// 3. Submit CSR to remote enrollment endpoint
|
||||||
|
console.log('Submitting CSR to remote enrollment endpoint...');
|
||||||
|
const remoteRes = await fetch(enrollmentUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ csrPem }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!remoteRes.ok) {
|
||||||
|
const errText = await remoteRes.text();
|
||||||
|
throw new Error(`Remote enrollment failed (${remoteRes.status.toString()}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteResult = (await remoteRes.json()) as { certPem: string; certChainPem: string };
|
||||||
|
|
||||||
|
if (!remoteResult.certPem) {
|
||||||
|
throw new Error('Remote enrollment response missing certPem');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store the signed certificate in the local gateway
|
||||||
|
console.log('Storing signed certificate...');
|
||||||
|
await apiRequest<Record<string, unknown>>(
|
||||||
|
opts,
|
||||||
|
'PATCH',
|
||||||
|
`/api/admin/federation/peers/${peerId}/cert`,
|
||||||
|
{ certPem: remoteResult.certPem },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\nPeer enrolled successfully.`);
|
||||||
|
console.log(` ID: ${peerId}`);
|
||||||
|
console.log(` State: active`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user