feat(federation): enrollment controller + single-use token flow (FED-M2-07) (#497)
This commit was merged in pull request #497.
This commit is contained in:
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminGuard } from '../admin/admin.guard.js';
|
||||||
import { CaService } from './ca.service.js';
|
import { CaService } from './ca.service.js';
|
||||||
|
import { EnrollmentController } from './enrollment.controller.js';
|
||||||
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [CaService, GrantsService],
|
controllers: [EnrollmentController],
|
||||||
exports: [CaService, GrantsService],
|
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
||||||
|
exports: [CaService, EnrollmentService, GrantsService],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
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;
|
||||||
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
@@ -71,6 +71,13 @@
|
|||||||
"when": 1745280000000,
|
"when": 1745280000000,
|
||||||
"tag": "0009_federation_grant_pending",
|
"tag": "0009_federation_grant_pending",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745366400000,
|
||||||
|
"tag": "0010_federation_enrollment_tokens",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,4 +17,5 @@ export {
|
|||||||
federationPeers,
|
federationPeers,
|
||||||
federationGrants,
|
federationGrants,
|
||||||
federationAuditLog,
|
federationAuditLog,
|
||||||
|
federationEnrollmentTokens,
|
||||||
} from './schema.js';
|
} from './schema.js';
|
||||||
|
|||||||
@@ -778,3 +778,34 @@ export const federationAuditLog = pgTable(
|
|||||||
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user