Compare commits
8 Commits
feat/feder
...
feat/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84706e122 | ||
|
|
5ea040af4c | ||
|
|
04b62539c7 | ||
| 3eee176cc3 | |||
| 74fe60d8d6 | |||
| 0bfaa56e9e | |||
| 01dd6b9fa1 | |||
| 1038ae76e1 |
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
|
||||
*
|
||||
* Covers MILESTONES.md acceptance test #6:
|
||||
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
|
||||
*
|
||||
* This test simulates two gateways using a single bootstrapped NestJS app:
|
||||
* - "Server A": the admin API that generates a keypair and stores the cert
|
||||
* - "Server B": the enrollment endpoint that signs the CSR
|
||||
* Both share the same DB + Step-CA in the test environment.
|
||||
*
|
||||
* Prerequisites:
|
||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
*
|
||||
* Run:
|
||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||
* STEP_CA_URL=https://localhost:9000 \
|
||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||
* pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
|
||||
*
|
||||
* Obtaining Step-CA credentials:
|
||||
* # Extract provisioner key from running container:
|
||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||
* # Copy root cert from container:
|
||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||
*
|
||||
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import supertest from 'supertest';
|
||||
import {
|
||||
createDb,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
federationPeers,
|
||||
federationGrants,
|
||||
federationEnrollmentTokens,
|
||||
inArray,
|
||||
eq,
|
||||
} from '@mosaicstack/db';
|
||||
import * as schema from '@mosaicstack/db';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { AdminGuard } from '../../admin/admin.guard.js';
|
||||
import { FederationModule } from '../../federation/federation.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const stepCaRun =
|
||||
run &&
|
||||
process.env['STEP_CA_AVAILABLE'] === '1' &&
|
||||
!!process.env['STEP_CA_URL'] &&
|
||||
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
|
||||
!!process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
|
||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
const RUN_ID = crypto.randomUUID();
|
||||
|
||||
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let app: NestFastifyApplication;
|
||||
let agent: ReturnType<typeof supertest>;
|
||||
let grantsService: GrantsService;
|
||||
let enrollmentService: EnrollmentService;
|
||||
|
||||
const createdTokenGrantIds: string[] = [];
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
|
||||
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [FederationModule],
|
||||
providers: [{ provide: DB, useValue: db }],
|
||||
})
|
||||
.overrideGuard(AdminGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
agent = supertest(app.getHttpServer());
|
||||
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
enrollmentService = moduleRef.get(EnrollmentService);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db && createdTokenGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (app)
|
||||
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #6 — peer add: keypair → enrollment → cert storage → active peer record
|
||||
// -------------------------------------------------------------------------
|
||||
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
|
||||
// Create a subject user to satisfy FK on federation_grants.subject_user_id
|
||||
const userId = crypto.randomUUID();
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: userId,
|
||||
name: `e2e-user-${RUN_ID}`,
|
||||
email: `e2e-${RUN_ID}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
createdUserIds.push(userId);
|
||||
|
||||
// ── Step A: "Server B" setup ─────────────────────────────────────────
|
||||
// Server B admin creates a grant and generates an enrollment token to
|
||||
// share out-of-band with Server A's operator.
|
||||
|
||||
// Insert a placeholder peer on "Server B" to satisfy the grant FK
|
||||
const serverBPeerId = crypto.randomUUID();
|
||||
await db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
id: serverBPeerId,
|
||||
commonName: `server-b-peer-${RUN_ID}`,
|
||||
displayName: 'Server B Placeholder',
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `serial-b-${serverBPeerId}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
createdPeerIds.push(serverBPeerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
||||
peerId: serverBPeerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
createdTokenGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId: serverBPeerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
// ── Step B: "Server A" generates keypair ─────────────────────────────
|
||||
const keypairRes = await agent
|
||||
.post('/api/admin/federation/peers/keypair')
|
||||
.send({
|
||||
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
|
||||
displayName: 'E2E Test Peer',
|
||||
endpointUrl: 'https://test.invalid',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(keypairRes.status).toBe(201);
|
||||
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
|
||||
expect(typeof peerId).toBe('string');
|
||||
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
|
||||
const enrollRes = await agent
|
||||
.post(`/api/federation/enrollment/${token}`)
|
||||
.send({ csrPem })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(enrollRes.status).toBe(200);
|
||||
const { certPem, certChainPem } = enrollRes.body as {
|
||||
certPem: string;
|
||||
certChainPem: string;
|
||||
};
|
||||
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// ── Step D: "Server A" stores the cert ───────────────────────────────
|
||||
const storeRes = await agent
|
||||
.patch(`/api/admin/federation/peers/${peerId}/cert`)
|
||||
.send({ certPem })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(storeRes.status).toBe(200);
|
||||
|
||||
// ── Step E: Verify peer record in DB ─────────────────────────────────
|
||||
const [peer] = await db
|
||||
.select()
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, peerId))
|
||||
.limit(1);
|
||||
|
||||
expect(peer).toBeDefined();
|
||||
expect(peer?.state).toBe('active');
|
||||
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(typeof peer?.certSerial).toBe('string');
|
||||
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
|
||||
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
|
||||
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||
// certNotAfter must be in the future
|
||||
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Federation M2 integration tests (FED-M2-09).
|
||||
*
|
||||
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
|
||||
*
|
||||
* Prerequisites:
|
||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
*
|
||||
* Run DB-only tests (no Step-CA):
|
||||
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||
*
|
||||
* Run all tests including Step-CA-dependent ones:
|
||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||
* STEP_CA_URL=https://localhost:9000 \
|
||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||
* pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||
*
|
||||
* Obtaining Step-CA credentials:
|
||||
* # Extract provisioner key from running container:
|
||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||
* # Copy root cert from container:
|
||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { GoneException } from '@nestjs/common';
|
||||
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
|
||||
import {
|
||||
createDb,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
federationPeers,
|
||||
federationGrants,
|
||||
federationEnrollmentTokens,
|
||||
inArray,
|
||||
eq,
|
||||
} from '@mosaicstack/db';
|
||||
import * as schema from '@mosaicstack/db';
|
||||
import { seal } from '@mosaicstack/auth';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||
import { CaService } from '../../federation/ca.service.js';
|
||||
import { FederationScopeError } from '../../federation/scope-schema.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
|
||||
|
||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for test data isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Unique run prefix to identify rows created by this test run. */
|
||||
const RUN_ID = crypto.randomUUID();
|
||||
|
||||
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
|
||||
async function insertTestUser(db: Db, id: string): Promise<void> {
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id,
|
||||
name: `test-user-${id}`,
|
||||
email: `test-${id}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
|
||||
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
|
||||
await db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
id,
|
||||
commonName: `test-peer-${RUN_ID}-${suffix}`,
|
||||
displayName: `Test Peer ${suffix}`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `test-serial-${id}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB-only test module (CaService mocked so env vars not required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDbModule(db: Db) {
|
||||
return Test.createTestingModule({
|
||||
providers: [
|
||||
{ provide: DB, useValue: db },
|
||||
GrantsService,
|
||||
{
|
||||
provide: CaService,
|
||||
useValue: {
|
||||
issueCert: async () => {
|
||||
throw new Error('CaService.issueCert should not be called in DB-only tests');
|
||||
},
|
||||
},
|
||||
},
|
||||
EnrollmentService,
|
||||
],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — DB-only (no Step-CA)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let grantsService: GrantsService;
|
||||
|
||||
/** IDs created during this run — cleaned up in afterAll. */
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
|
||||
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
const moduleRef = await buildDbModule(db);
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up in FK-safe order: tokens → grants → peers → users
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #1 — grant create writes a pending row
|
||||
// -------------------------------------------------------------------------
|
||||
it('#1 — createGrant writes a pending row to DB', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test1');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
// Verify the row exists in DB with correct shape
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(federationGrants)
|
||||
.where(eq(federationGrants.id, grant.id))
|
||||
.limit(1);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.status).toBe('pending');
|
||||
expect(row?.peerId).toBe(peerId);
|
||||
expect(row?.subjectUserId).toBe(userId);
|
||||
const storedScope = row?.scope as Record<string, unknown>;
|
||||
expect(storedScope['resources']).toEqual(['tasks']);
|
||||
expect(storedScope['max_rows_per_query']).toBe(100);
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #7 — scope with unknown resource type rejected
|
||||
// -------------------------------------------------------------------------
|
||||
it('#7 — createGrant rejects scope with unknown resource type', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const invalidScope = {
|
||||
resources: ['totally_unknown_resource'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test7');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
await expect(
|
||||
grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: invalidScope,
|
||||
peerId,
|
||||
}),
|
||||
).rejects.toThrow(FederationScopeError);
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #8 — listGrants returns accurate status for grants in various states
|
||||
// -------------------------------------------------------------------------
|
||||
it('#8 — listGrants returns accurate status for grants in various states', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['notes'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 50,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test8');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
// Create two pending grants via GrantsService
|
||||
const grantA = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
const grantB = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grantA.id, grantB.id);
|
||||
|
||||
// Insert a third grant directly in 'revoked' state to test status variety
|
||||
const [grantC] = await db
|
||||
.insert(federationGrants)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
subjectUserId: userId,
|
||||
peerId,
|
||||
scope: validScope,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
createdGrantIds.push(grantC!.id);
|
||||
|
||||
// List all grants for this peer
|
||||
const allForPeer = await grantsService.listGrants({ peerId });
|
||||
|
||||
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
|
||||
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
|
||||
expect(ourGrants).toHaveLength(3);
|
||||
|
||||
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
|
||||
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
|
||||
expect(pendingGrants).toHaveLength(2);
|
||||
expect(revokedGrants).toHaveLength(1);
|
||||
|
||||
// Status-filtered query
|
||||
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
|
||||
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
|
||||
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
|
||||
|
||||
// Verify peer list from DB also shows the peer rows with correct state
|
||||
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
|
||||
expect(peers).toHaveLength(1);
|
||||
expect(peers[0]?.state).toBe('pending');
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #5 — client_key_pem encrypted at rest
|
||||
// -------------------------------------------------------------------------
|
||||
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
|
||||
const peerId = crypto.randomUUID();
|
||||
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
|
||||
const sealed = seal(rawPem);
|
||||
|
||||
await db.insert(federationPeers).values({
|
||||
id: peerId,
|
||||
commonName: `test-peer-${RUN_ID}-sealed`,
|
||||
displayName: 'Sealed Key Test Peer',
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `test-serial-sealed-${peerId}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
clientKeyPem: sealed,
|
||||
});
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, peerId))
|
||||
.limit(1);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
|
||||
expect(row?.clientKeyPem).toBeDefined();
|
||||
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||
// The sealed value should be non-trivial (at least 20 chars)
|
||||
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — Step-CA gated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let grantsService: GrantsService;
|
||||
let enrollmentService: EnrollmentService;
|
||||
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
|
||||
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
|
||||
}).compile();
|
||||
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
enrollmentService = moduleRef.get(EnrollmentService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
});
|
||||
|
||||
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
|
||||
async function generateCsrPem(cn: string): Promise<string> {
|
||||
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
|
||||
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${cn}`,
|
||||
keys: keyPair,
|
||||
signingAlgorithm: alg,
|
||||
});
|
||||
return csr.toString('pem');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #2 — enrollment signs CSR and returns cert
|
||||
// -------------------------------------------------------------------------
|
||||
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'ca-test2');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
|
||||
const result = await enrollmentService.redeem(token, csrPem);
|
||||
|
||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// Verify the issued cert parses cleanly
|
||||
const cert = new PeculiarX509(result.certPem);
|
||||
expect(cert.serialNumber).toBeTruthy();
|
||||
}, 30_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #3 — token single-use; second attempt returns GoneException
|
||||
// -------------------------------------------------------------------------
|
||||
it('#3 — second redeem of the same token throws GoneException', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['notes'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 50,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'ca-test3');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
|
||||
|
||||
// First redeem must succeed
|
||||
const result = await enrollmentService.redeem(token, csrPem);
|
||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// Second redeem with the same token must be rejected
|
||||
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
|
||||
}, 30_000);
|
||||
});
|
||||
373
apps/gateway/src/federation/__tests__/enrollment.service.spec.ts
Normal file
373
apps/gateway/src/federation/__tests__/enrollment.service.spec.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Unit tests for EnrollmentService — federation enrollment token flow (FED-M2-07).
|
||||
*
|
||||
* Coverage:
|
||||
* createToken:
|
||||
* - inserts token row with correct grantId, peerId, and future expiresAt
|
||||
* - returns { token, expiresAt } with a 64-char hex token
|
||||
* - clamps ttlSeconds to 900
|
||||
*
|
||||
* redeem — error paths:
|
||||
* - NotFoundException when token row not found
|
||||
* - GoneException when token already used (usedAt set)
|
||||
* - GoneException when token expired (expiresAt < now)
|
||||
* - GoneException when grant status is not pending
|
||||
*
|
||||
* redeem — success path:
|
||||
* - atomically claims token BEFORE cert issuance (claim → issueCert → tx)
|
||||
* - calls CaService.issueCert with correct args
|
||||
* - activates grant + updates peer + writes audit log inside a transaction
|
||||
* - returns { certPem, certChainPem }
|
||||
*
|
||||
* redeem — replay protection:
|
||||
* - GoneException when claim UPDATE returns empty array (concurrent request won)
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GoneException, NotFoundException } from '@nestjs/common';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { EnrollmentService } from '../enrollment.service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 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 TOKEN = 'a'.repeat(64); // 64-char hex
|
||||
|
||||
const MOCK_CERT_PEM = '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n';
|
||||
const MOCK_CHAIN_PEM = MOCK_CERT_PEM + MOCK_CERT_PEM;
|
||||
const MOCK_SERIAL = 'ABCD1234';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTokenRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
token: TOKEN,
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
expiresAt: new Date(Date.now() + 60_000), // 1 min from now
|
||||
usedAt: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
||||
status: 'pending',
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock DB builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeDb({
|
||||
tokenRows = [makeTokenRow()],
|
||||
// claimedRows is returned by the .returning() on the token-claim UPDATE.
|
||||
// Empty array = concurrent request won the race (GoneException).
|
||||
claimedRows = [{ token: TOKEN }],
|
||||
}: {
|
||||
tokenRows?: unknown[];
|
||||
claimedRows?: unknown[];
|
||||
} = {}) {
|
||||
// insert().values() — for createToken (outer db, not tx)
|
||||
const insertValues = vi.fn().mockResolvedValue(undefined);
|
||||
const insertMock = vi.fn().mockReturnValue({ values: insertValues });
|
||||
|
||||
// select().from().where().limit() — for fetching the token row
|
||||
const limitSelect = vi.fn().mockResolvedValue(tokenRows);
|
||||
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||
|
||||
// update().set().where().returning() — for the atomic token claim (outer db)
|
||||
const returningMock = vi.fn().mockResolvedValue(claimedRows);
|
||||
const whereClaimUpdate = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const setClaimMock = vi.fn().mockReturnValue({ where: whereClaimUpdate });
|
||||
const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock });
|
||||
|
||||
// transaction(cb) — cb receives txMock; txMock has update + insert
|
||||
const txInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
|
||||
const txWhereUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate });
|
||||
const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock });
|
||||
const txMock = { update: txUpdateMock, insert: txInsertMock };
|
||||
const transactionMock = vi
|
||||
.fn()
|
||||
.mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock));
|
||||
|
||||
return {
|
||||
insert: insertMock,
|
||||
select: selectMock,
|
||||
update: claimUpdateMock,
|
||||
transaction: transactionMock,
|
||||
_mocks: {
|
||||
insertValues,
|
||||
insertMock,
|
||||
limitSelect,
|
||||
whereSelect,
|
||||
fromSelect,
|
||||
selectMock,
|
||||
returningMock,
|
||||
whereClaimUpdate,
|
||||
setClaimMock,
|
||||
claimUpdateMock,
|
||||
txInsertValues,
|
||||
txInsertMock,
|
||||
txWhereUpdate,
|
||||
txSetMock,
|
||||
txUpdateMock,
|
||||
txMock,
|
||||
transactionMock,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock CaService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeCaService() {
|
||||
return {
|
||||
issueCert: vi.fn().mockResolvedValue({
|
||||
certPem: MOCK_CERT_PEM,
|
||||
certChainPem: MOCK_CHAIN_PEM,
|
||||
serialNumber: MOCK_SERIAL,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock GrantsService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeGrantsService(grantOverrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
getGrant: vi.fn().mockResolvedValue(makeGrant(grantOverrides)),
|
||||
activateGrant: vi.fn().mockResolvedValue(makeGrant({ status: 'active' })),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build service under test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildService({
|
||||
db = makeDb(),
|
||||
caService = makeCaService(),
|
||||
grantsService = makeGrantsService(),
|
||||
}: {
|
||||
db?: ReturnType<typeof makeDb>;
|
||||
caService?: ReturnType<typeof makeCaService>;
|
||||
grantsService?: ReturnType<typeof makeGrantsService>;
|
||||
} = {}) {
|
||||
return new EnrollmentService(db as unknown as Db, caService as never, grantsService as never);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: createToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnrollmentService.createToken', () => {
|
||||
it('inserts a token row and returns { token, expiresAt }', async () => {
|
||||
const db = makeDb();
|
||||
const service = buildService({ db });
|
||||
|
||||
const result = await service.createToken({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
expect(result.token).toHaveLength(64); // 32 bytes hex
|
||||
expect(result.expiresAt).toBeDefined();
|
||||
expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||
expect(db._mocks.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ grantId: GRANT_ID, peerId: PEER_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps ttlSeconds to 900', async () => {
|
||||
const db = makeDb();
|
||||
const service = buildService({ db });
|
||||
|
||||
const before = Date.now();
|
||||
const result = await service.createToken({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
ttlSeconds: 9999,
|
||||
});
|
||||
const after = Date.now();
|
||||
|
||||
const expiresMs = new Date(result.expiresAt).getTime();
|
||||
// Should be at most 900s from now
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: redeem — error paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnrollmentService.redeem — error paths', () => {
|
||||
it('throws NotFoundException when token row not found', async () => {
|
||||
const db = makeDb({ tokenRows: [] });
|
||||
const service = buildService({ db });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws GoneException when usedAt is set (already redeemed)', async () => {
|
||||
const db = makeDb({ tokenRows: [makeTokenRow({ usedAt: new Date(Date.now() - 1000) })] });
|
||||
const service = buildService({ db });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
});
|
||||
|
||||
it('throws GoneException when token has expired', async () => {
|
||||
const db = makeDb({ tokenRows: [makeTokenRow({ expiresAt: new Date(Date.now() - 1000) })] });
|
||||
const service = buildService({ db });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
});
|
||||
|
||||
it('throws GoneException when grant status is not pending', async () => {
|
||||
const db = makeDb();
|
||||
const grantsService = makeGrantsService({ status: 'active' });
|
||||
const service = buildService({ db, grantsService });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
});
|
||||
|
||||
it('throws GoneException when token claim UPDATE returns empty array (concurrent replay)', async () => {
|
||||
const db = makeDb({ claimedRows: [] });
|
||||
const caService = makeCaService();
|
||||
const grantsService = makeGrantsService();
|
||||
const service = buildService({ db, caService, grantsService });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
});
|
||||
|
||||
it('does NOT call issueCert when token claim fails (no double minting)', async () => {
|
||||
const db = makeDb({ claimedRows: [] });
|
||||
const caService = makeCaService();
|
||||
const service = buildService({ db, caService });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
expect(caService.issueCert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: redeem — success path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnrollmentService.redeem — success path', () => {
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
let caService: ReturnType<typeof makeCaService>;
|
||||
let grantsService: ReturnType<typeof makeGrantsService>;
|
||||
let service: EnrollmentService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
caService = makeCaService();
|
||||
grantsService = makeGrantsService();
|
||||
service = buildService({ db, caService, grantsService });
|
||||
});
|
||||
|
||||
it('claims token BEFORE calling issueCert (prevents double minting)', async () => {
|
||||
const callOrder: string[] = [];
|
||||
db._mocks.returningMock.mockImplementation(async () => {
|
||||
callOrder.push('claim');
|
||||
return [{ token: TOKEN }];
|
||||
});
|
||||
caService.issueCert.mockImplementation(async () => {
|
||||
callOrder.push('issueCert');
|
||||
return { certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL };
|
||||
});
|
||||
|
||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||
|
||||
expect(callOrder).toEqual(['claim', 'issueCert']);
|
||||
});
|
||||
|
||||
it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => {
|
||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||
|
||||
expect(caService.issueCert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: USER_ID,
|
||||
csrPem: MOCK_CERT_PEM,
|
||||
ttlSeconds: 300,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs activate grant + peer update + audit inside a transaction', async () => {
|
||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||
|
||||
expect(db._mocks.transactionMock).toHaveBeenCalledOnce();
|
||||
// tx.update called twice: activate grant + update peer
|
||||
expect(db._mocks.txUpdateMock).toHaveBeenCalledTimes(2);
|
||||
// tx.insert called once: audit log
|
||||
expect(db._mocks.txInsertMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('activates grant (sets status=active) inside the transaction', async () => {
|
||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||
|
||||
expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
|
||||
});
|
||||
|
||||
it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => {
|
||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||
|
||||
expect(db._mocks.txSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
certPem: MOCK_CERT_PEM,
|
||||
certSerial: MOCK_SERIAL,
|
||||
state: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('inserts an audit log row inside the transaction', async () => {
|
||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||
|
||||
expect(db._mocks.txInsertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
peerId: PEER_ID,
|
||||
grantId: GRANT_ID,
|
||||
verb: 'enrollment',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns { certPem, certChainPem } from CaService', async () => {
|
||||
const result = await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||
|
||||
expect(result).toEqual({
|
||||
certPem: MOCK_CERT_PEM,
|
||||
certChainPem: MOCK_CHAIN_PEM,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
351
apps/gateway/src/federation/__tests__/grants.service.spec.ts
Normal file
351
apps/gateway/src/federation/__tests__/grants.service.spec.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Unit tests for GrantsService — federation grants CRUD + status transitions (FED-M2-06).
|
||||
*
|
||||
* Coverage:
|
||||
* - createGrant: validates scope via parseFederationScope
|
||||
* - createGrant: inserts with status 'pending'
|
||||
* - getGrant: returns grant when found
|
||||
* - getGrant: throws NotFoundException when not found
|
||||
* - listGrants: no filters returns all grants
|
||||
* - listGrants: filters by peerId
|
||||
* - listGrants: filters by subjectUserId
|
||||
* - listGrants: filters by status
|
||||
* - listGrants: multiple filters combined
|
||||
* - activateGrant: pending → active works
|
||||
* - activateGrant: non-pending throws ConflictException
|
||||
* - revokeGrant: active → revoked works, sets revokedAt
|
||||
* - revokeGrant: non-active throws ConflictException
|
||||
* - expireGrant: active → expired works
|
||||
* - expireGrant: non-active throws ConflictException
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { GrantsService } from '../grants.service.js';
|
||||
import { FederationScopeError } from '../scope-schema.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal valid federation scope for testing
|
||||
// ---------------------------------------------------------------------------
|
||||
const VALID_SCOPE = {
|
||||
resources: ['tasks'] as const,
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
const PEER_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||
const USER_ID = 'u2222222-2222-2222-2222-222222222222';
|
||||
const GRANT_ID = 'g3333333-3333-3333-3333-333333333333';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a mock DB that mimics chained Drizzle query builder calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
status: 'pending',
|
||||
expiresAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb(
|
||||
overrides: {
|
||||
insertReturning?: unknown[];
|
||||
selectRows?: unknown[];
|
||||
updateReturning?: unknown[];
|
||||
} = {},
|
||||
) {
|
||||
const insertReturning = overrides.insertReturning ?? [makeMockGrant()];
|
||||
const selectRows = overrides.selectRows ?? [makeMockGrant()];
|
||||
const updateReturning = overrides.updateReturning ?? [makeMockGrant({ status: 'active' })];
|
||||
|
||||
// Drizzle returns a chainable builder; we need to mock the full chain.
|
||||
const returningInsert = vi.fn().mockResolvedValue(insertReturning);
|
||||
const valuesInsert = vi.fn().mockReturnValue({ returning: returningInsert });
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesInsert });
|
||||
|
||||
// select().from().where().limit()
|
||||
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
||||
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||
// from returns something that is both thenable (for full-table select) and has .where()
|
||||
const fromSelect = vi.fn().mockReturnValue({
|
||||
where: whereSelect,
|
||||
limit: limitSelect,
|
||||
// Make it thenable for listGrants with no filters (await db.select().from(federationGrants))
|
||||
then: (resolve: (v: unknown) => unknown) => resolve(selectRows),
|
||||
});
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||
|
||||
const returningUpdate = vi.fn().mockResolvedValue(updateReturning);
|
||||
const whereUpdate = vi.fn().mockReturnValue({ returning: returningUpdate });
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdate });
|
||||
const updateMock = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
return {
|
||||
insert: insertMock,
|
||||
select: selectMock,
|
||||
update: updateMock,
|
||||
// Expose internals for assertions
|
||||
_mocks: {
|
||||
insertReturning,
|
||||
valuesInsert,
|
||||
insertMock,
|
||||
limitSelect,
|
||||
whereSelect,
|
||||
fromSelect,
|
||||
selectMock,
|
||||
returningUpdate,
|
||||
whereUpdate,
|
||||
setMock,
|
||||
updateMock,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GrantsService', () => {
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
let service: GrantsService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
});
|
||||
|
||||
// ─── createGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('createGrant', () => {
|
||||
it('calls parseFederationScope — rejects an invalid scope', async () => {
|
||||
const invalidScope = { resources: [], max_rows_per_query: 0 };
|
||||
await expect(
|
||||
service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: invalidScope }),
|
||||
).rejects.toBeInstanceOf(FederationScopeError);
|
||||
});
|
||||
|
||||
it('inserts a grant with status pending and returns it', async () => {
|
||||
const result = await service.createGrant({
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
});
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'pending', peerId: PEER_ID, subjectUserId: USER_ID }),
|
||||
);
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('passes expiresAt as a Date when provided', async () => {
|
||||
await service.createGrant({
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
expiresAt: '2027-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets expiresAt to null when not provided', async () => {
|
||||
await service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: VALID_SCOPE });
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getGrant ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getGrant', () => {
|
||||
it('returns the grant when found', async () => {
|
||||
const result = await service.getGrant(GRANT_ID);
|
||||
expect(result.id).toBe(GRANT_ID);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when no rows returned', async () => {
|
||||
db = makeDb({ selectRows: [] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
await expect(service.getGrant(GRANT_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── listGrants ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('listGrants', () => {
|
||||
it('queries without where clause when no filters provided', async () => {
|
||||
const result = await service.listGrants({});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies peerId filter', async () => {
|
||||
await service.listGrants({ peerId: PEER_ID });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies subjectUserId filter', async () => {
|
||||
await service.listGrants({ subjectUserId: USER_ID });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies status filter', async () => {
|
||||
await service.listGrants({ status: 'active' });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies multiple filters combined', async () => {
|
||||
await service.listGrants({ peerId: PEER_ID, status: 'pending' });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── activateGrant ────────────────────────────────────────────────────────
|
||||
|
||||
describe('activateGrant', () => {
|
||||
it('transitions pending → active and returns updated grant', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'pending' })],
|
||||
updateReturning: [makeMockGrant({ status: 'active' })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.activateGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'active' });
|
||||
expect(result.status).toBe('active');
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already active', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'active' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── revokeGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('revokeGrant', () => {
|
||||
it('transitions active → revoked and sets revokedAt', async () => {
|
||||
const revokedAt = new Date();
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.revokeGrant(GRANT_ID, 'test reason');
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'revoked',
|
||||
revokedAt: expect.any(Date),
|
||||
revokedReason: 'test reason',
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('revoked');
|
||||
});
|
||||
|
||||
it('sets revokedReason to null when not provided', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt: new Date() })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await service.revokeGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ revokedReason: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is pending', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── expireGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('expireGrant', () => {
|
||||
it('transitions active → expired and returns updated grant', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'expired' })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.expireGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'expired' });
|
||||
expect(result.status).toBe('expired');
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is pending', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
});
|
||||
54
apps/gateway/src/federation/enrollment.controller.ts
Normal file
54
apps/gateway/src/federation/enrollment.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* EnrollmentController — federation enrollment HTTP layer (FED-M2-07).
|
||||
*
|
||||
* Routes:
|
||||
* POST /api/federation/enrollment/tokens — admin creates a single-use token
|
||||
* POST /api/federation/enrollment/:token — unauthenticated; token IS the auth
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { CreateEnrollmentTokenDto, RedeemEnrollmentTokenDto } from './enrollment.dto.js';
|
||||
|
||||
@Controller('api/federation/enrollment')
|
||||
export class EnrollmentController {
|
||||
constructor(@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService) {}
|
||||
|
||||
/**
|
||||
* Admin-only: generate a single-use enrollment token for a pending grant.
|
||||
* The token should be distributed out-of-band to the remote peer operator.
|
||||
*
|
||||
* POST /api/federation/enrollment/tokens
|
||||
*/
|
||||
@Post('tokens')
|
||||
@UseGuards(AdminGuard)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createToken(@Body() dto: CreateEnrollmentTokenDto) {
|
||||
return this.enrollmentService.createToken(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticated: remote peer redeems a token by submitting its CSR.
|
||||
* The token itself is the credential — no session or bearer token required.
|
||||
*
|
||||
* POST /api/federation/enrollment/:token
|
||||
*
|
||||
* Returns the signed leaf cert and full chain PEM on success.
|
||||
* Returns 410 Gone if the token was already used or has expired.
|
||||
*/
|
||||
@Post(':token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async redeem(@Param('token') token: string, @Body() dto: RedeemEnrollmentTokenDto) {
|
||||
return this.enrollmentService.redeem(token, dto.csrPem);
|
||||
}
|
||||
}
|
||||
35
apps/gateway/src/federation/enrollment.dto.ts
Normal file
35
apps/gateway/src/federation/enrollment.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* DTOs for the federation enrollment flow (FED-M2-07).
|
||||
*
|
||||
* CreateEnrollmentTokenDto — admin generates a single-use enrollment token
|
||||
* RedeemEnrollmentTokenDto — remote peer submits CSR to redeem the token
|
||||
*/
|
||||
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class CreateEnrollmentTokenDto {
|
||||
/** UUID of the federation grant this token will activate on redemption. */
|
||||
@IsUUID()
|
||||
grantId!: string;
|
||||
|
||||
/** UUID of the peer record that will receive the issued cert on redemption. */
|
||||
@IsUUID()
|
||||
peerId!: string;
|
||||
|
||||
/**
|
||||
* Token lifetime in seconds. Default 900 (15 min). Min 60. Max 900.
|
||||
* After this time the token is rejected even if unused.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(60)
|
||||
@Max(900)
|
||||
ttlSeconds: number = 900;
|
||||
}
|
||||
|
||||
export class RedeemEnrollmentTokenDto {
|
||||
/** PEM-encoded PKCS#10 Certificate Signing Request from the remote peer. */
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
csrPem!: string;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { CaService } from './ca.service.js';
|
||||
import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [CaService],
|
||||
exports: [CaService],
|
||||
controllers: [EnrollmentController, FederationController],
|
||||
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
||||
exports: [CaService, EnrollmentService, GrantsService],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
36
apps/gateway/src/federation/grants.dto.ts
Normal file
36
apps/gateway/src/federation/grants.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IsDateString, IsIn, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateGrantDto {
|
||||
@IsUUID()
|
||||
peerId!: string;
|
||||
|
||||
@IsUUID()
|
||||
subjectUserId!: string;
|
||||
|
||||
@IsObject()
|
||||
scope!: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class ListGrantsDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
peerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
subjectUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['pending', 'active', 'revoked', 'expired'])
|
||||
status?: 'pending' | 'active' | 'revoked' | 'expired';
|
||||
}
|
||||
|
||||
export class RevokeGrantDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
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!;
|
||||
}
|
||||
}
|
||||
2
packages/db/drizzle/0009_federation_grant_pending.sql
Normal file
2
packages/db/drizzle/0009_federation_grant_pending.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE "public"."grant_status" ADD VALUE 'pending' BEFORE 'active';--> statement-breakpoint
|
||||
ALTER TABLE "federation_grants" ALTER COLUMN "status" SET DEFAULT 'pending';
|
||||
11
packages/db/drizzle/0010_federation_enrollment_tokens.sql
Normal file
11
packages/db/drizzle/0010_federation_enrollment_tokens.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "federation_enrollment_tokens" (
|
||||
"token" text PRIMARY KEY NOT NULL,
|
||||
"grant_id" uuid NOT NULL,
|
||||
"peer_id" uuid NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "federation_enrollment_tokens" ADD CONSTRAINT "federation_enrollment_tokens_grant_id_federation_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."federation_grants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "federation_enrollment_tokens" ADD CONSTRAINT "federation_enrollment_tokens_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;
|
||||
3376
packages/db/drizzle/meta/0009_snapshot.json
Normal file
3376
packages/db/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3453
packages/db/drizzle/meta/0010_snapshot.json
Normal file
3453
packages/db/drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,20 @@
|
||||
"when": 1776822435828,
|
||||
"tag": "0008_smart_lyja",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1745280000000,
|
||||
"tag": "0009_federation_grant_pending",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1745366400000,
|
||||
"tag": "0010_federation_enrollment_tokens",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,4 +17,5 @@ export {
|
||||
federationPeers,
|
||||
federationGrants,
|
||||
federationAuditLog,
|
||||
federationEnrollmentTokens,
|
||||
} from './schema.js';
|
||||
|
||||
@@ -604,11 +604,12 @@ export const peerStateEnum = pgEnum('peer_state', ['pending', 'active', 'suspend
|
||||
|
||||
/**
|
||||
* Lifecycle state of a federation grant.
|
||||
* - pending: created but not yet activated (awaiting cert enrollment, M2-07)
|
||||
* - active: grant is in effect
|
||||
* - revoked: manually revoked before expiry
|
||||
* - expired: natural expiry (expires_at passed)
|
||||
*/
|
||||
export const grantStatusEnum = pgEnum('grant_status', ['active', 'revoked', 'expired']);
|
||||
export const grantStatusEnum = pgEnum('grant_status', ['pending', 'active', 'revoked', 'expired']);
|
||||
|
||||
/**
|
||||
* A registered peer gateway identified by its Step-CA certificate CN.
|
||||
@@ -696,7 +697,7 @@ export const federationGrants = pgTable(
|
||||
scope: jsonb('scope').notNull(),
|
||||
|
||||
/** Current grant lifecycle state. */
|
||||
status: grantStatusEnum('status').notNull().default('active'),
|
||||
status: grantStatusEnum('status').notNull().default('pending'),
|
||||
|
||||
/** Optional hard expiry. NULL means the grant does not expire automatically. */
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
@@ -777,3 +778,34 @@ export const federationAuditLog = pgTable(
|
||||
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Single-use enrollment tokens — M2-07.
|
||||
*
|
||||
* An admin creates a token (with a TTL) and hands it out-of-band to the
|
||||
* remote peer operator. The peer redeems it exactly once by posting its
|
||||
* CSR to POST /api/federation/enrollment/:token. The token is atomically
|
||||
* marked as used to prevent replay attacks.
|
||||
*/
|
||||
export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens', {
|
||||
/** 32-byte hex token — crypto.randomBytes(32).toString('hex') */
|
||||
token: text('token').primaryKey(),
|
||||
|
||||
/** The federation grant this enrollment activates. */
|
||||
grantId: uuid('grant_id')
|
||||
.notNull()
|
||||
.references(() => federationGrants.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** The peer record that will be updated on successful enrollment. */
|
||||
peerId: uuid('peer_id')
|
||||
.notNull()
|
||||
.references(() => federationPeers.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** Hard expiry — token rejected after this time even if not used. */
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
|
||||
/** NULL until the token is redeemed. Set atomically to prevent replay. */
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
// prdy is registered via launch.ts
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
import { registerFederationCommand } from './commands/federation.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
import {
|
||||
backgroundUpdateCheck,
|
||||
@@ -336,6 +337,10 @@ registerAuthCommand(program);
|
||||
|
||||
registerGatewayCommand(program);
|
||||
|
||||
// ─── federation ───────────────────────────────────────────────────────
|
||||
|
||||
registerFederationCommand(program);
|
||||
|
||||
// ─── agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
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