Implements the two halves of FED-M2-08: Gateway (apps/gateway/src/federation/): - federation-admin.dto.ts: CreatePeerKeypairDto, StorePeerCertDto, GenerateEnrollmentTokenDto, RevokeGrantBodyDto - federation.controller.ts: FederationController under /api/admin/federation with AdminGuard on all routes. Grant CRUD (create, list, get, revoke) delegating to GrantsService. Token generation delegating to EnrollmentService + returning enrollmentUrl. Peer listing via direct DB query. Peer keypair generation via webcrypto + @peculiar/x509 CSR generation. Peer cert storage with X509Certificate serial/notAfter extraction. - federation.module.ts: register FederationController CLI (packages/mosaic/src/commands/federation.ts): - mosaic federation (alias: fed) command group - grant create/list/show/revoke/token subcommands - peer list/add subcommands (add runs full enrollment flow) - Admin token resolved from -t flag or meta.json adminToken - packages/mosaic/src/cli.ts: register registerFederationCommand Tests (apps/gateway/src/federation/__tests__/federation.controller.spec.ts): - listGrants, createGrant, generateToken, listPeers coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
/**
|
|
* `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);
|
|
}
|
|
});
|
|
}
|