Merge: Phase 2 fleet factory enrollment + scoped rotatable tokens (§12) (#32)

This commit is contained in:
saravanakumardb1 2026-05-30 00:18:42 -07:00
commit 4df134ec96
8 changed files with 769 additions and 13 deletions

View File

@ -195,6 +195,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
fleet_profiles: { partitionKeyPath: '/productId' }, fleet_profiles: { partitionKeyPath: '/productId' },
fleet_events: { partitionKeyPath: '/jobId' }, fleet_events: { partitionKeyPath: '/jobId' },
fleet_artifacts: { partitionKeyPath: '/jobId' }, fleet_artifacts: { partitionKeyPath: '/jobId' },
fleet_factory_tokens: { partitionKeyPath: '/productId' },
}; };
export async function initCosmosIfNeeded(): Promise<void> { export async function initCosmosIfNeeded(): Promise<void> {

View File

@ -124,8 +124,8 @@ describe('fleet artifacts — blob service', () => {
contentType: 'image/png', contentType: 'image/png',
}); });
const a = await repo.listArtifactsByJob('jA'); const a = await repo.listArtifactsByJob('jA', PID);
const b = await repo.listArtifactsByJob('jB'); const b = await repo.listArtifactsByJob('jB', PID);
expect(a).toHaveLength(2); expect(a).toHaveLength(2);
expect(a.every(x => x.jobId === 'jA')).toBe(true); expect(a.every(x => x.jobId === 'jA')).toBe(true);
expect(b).toHaveLength(1); expect(b).toHaveLength(1);

View File

@ -0,0 +1,306 @@
/**
* Fleet factory enrollment + scoped tokens (§12). Unit tests run the lifecycle
* directly on the memory provider; route tests use Fastify inject to exercise the
* GATED token enforcement (default OFF) on heartbeat/claim + the management routes.
* Auth + productId resolution are mocked exactly like the other fleet routes tests.
*/
// Select the in-memory blob provider before the storage singleton is created
// (the artifact upload route test exercises blob storage).
process.env.STORAGE_PROVIDER = 'memory';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js';
import * as repo from './repository.js';
import * as enrollment from './enrollment.js';
vi.mock('../../lib/auth.js', () => ({
extractAuth: vi.fn(async () => ({ sub: 'user_1', role: 'admin' })),
}));
vi.mock('../../lib/request-context.js', () => ({
getRequestProductId: () => 'lysnrai',
}));
const PID = 'lysnrai';
async function buildApp(): Promise<FastifyInstance> {
const { fleetRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(fleetRoutes, { prefix: '/api' });
return app;
}
describe('fleet enrollment + scoped tokens', () => {
beforeEach(() => {
setProvider(new MemoryDatastoreProvider());
delete process.env.FLEET_REQUIRE_FACTORY_TOKEN;
delete process.env.FLEET_TOKEN_ROTATION_OVERLAP_SEC;
});
afterEach(() => {
_resetDatastoreProvider();
delete process.env.FLEET_REQUIRE_FACTORY_TOKEN;
delete process.env.FLEET_TOKEN_ROTATION_OVERLAP_SEC;
vi.clearAllMocks();
});
// ── Lifecycle (unit) ──
it('enroll returns a plaintext token once; stores ONLY a hash + scope', async () => {
const res = await enrollment.enrollFactory({
productId: PID,
factoryId: 'fac_1',
capabilities: ['os:mac', 'engine:devin'],
label: 'macbook',
});
expect(res.token).toMatch(/^flt_/);
expect(res.scope).toEqual({
productId: PID,
factoryId: 'fac_1',
capabilities: ['os:mac', 'engine:devin'],
});
// stored doc holds a sha256 hash (64 hex), NOT the plaintext, and no plaintext field
const stored = await repo.getFactoryToken(res.tokenId, PID);
expect(stored).not.toBeNull();
expect(stored?.tokenHash).toMatch(/^[0-9a-f]{64}$/);
expect(stored?.tokenHash).not.toBe(res.token);
expect(stored?.capabilities).toEqual(['os:mac', 'engine:devin']);
expect(JSON.stringify(stored)).not.toContain(res.token);
// the factory doc was created/linked
expect((await repo.getFactory('fac_1', PID))?.factoryId).toBe('fac_1');
});
it('verifyToken: valid → scope; tampered/unknown → null', async () => {
const res = await enrollment.enrollFactory({
productId: PID,
factoryId: 'fac_1',
capabilities: ['os:mac'],
});
const ok = await enrollment.verifyToken(res.token);
expect(ok).toMatchObject({
factoryId: 'fac_1',
productId: PID,
capabilities: ['os:mac'],
status: 'active',
});
expect(await enrollment.verifyToken(`${res.token}x`)).toBeNull();
expect(await enrollment.verifyToken('flt_unknown')).toBeNull();
expect(await enrollment.verifyToken(undefined)).toBeNull();
});
it('revoke: token is immediately rejected', async () => {
const res = await enrollment.enrollFactory({
productId: PID,
factoryId: 'fac_1',
capabilities: [],
});
expect(await enrollment.verifyToken(res.token)).not.toBeNull();
const n = await enrollment.revokeToken({ productId: PID, factoryId: 'fac_1' });
expect(n).toBe(1);
expect(await enrollment.verifyToken(res.token)).toBeNull();
});
it('rotate: old token works during the grace overlap, then is rejected; new token works', async () => {
const first = await enrollment.enrollFactory({
productId: PID,
factoryId: 'fac_1',
capabilities: ['os:mac'],
});
const second = await enrollment.rotateToken({ productId: PID, factoryId: 'fac_1' });
// both valid during the overlap; new token inherits the prior scope
expect(await enrollment.verifyToken(first.token)).toMatchObject({ status: 'rotating' });
expect(await enrollment.verifyToken(second.token)).toMatchObject({
status: 'active',
capabilities: ['os:mac'],
});
// once the grace window has elapsed, the old token is rejected
await repo.updateFactoryToken(first.tokenId, PID, {
rotatingUntil: new Date(Date.now() - 1000).toISOString(),
});
expect(await enrollment.verifyToken(first.token)).toBeNull();
expect(await enrollment.verifyToken(second.token)).not.toBeNull();
});
// ── Gated enforcement on heartbeat/claim ──
it('enforcement OFF (default): claim + heartbeat work with NO token', async () => {
const app = await buildApp();
await app.inject({
method: 'POST',
url: '/api/fleet/jobs',
payload: { idempotencyKey: 'k1', bodyMd: '# t' },
});
const claim = await app.inject({
method: 'POST',
url: '/api/fleet/claim',
payload: { factoryId: 'fac_1', capabilities: [] },
});
expect(claim.statusCode).toBe(200);
expect(JSON.parse(claim.body).claimed).toBe(true);
const hb = await app.inject({
method: 'POST',
url: '/api/fleet/factories/heartbeat',
payload: { factoryId: 'fac_1' },
});
expect(hb.statusCode).toBe(200);
});
it('enforcement ON: no token → 401; out-of-scope productId/capability → 403; in-scope → ok + constrained', async () => {
const app = await buildApp();
const enrolled = await enrollment.enrollFactory({
productId: PID,
factoryId: 'fac_1',
capabilities: ['os:mac'],
});
await app.inject({
method: 'POST',
url: '/api/fleet/jobs',
payload: { idempotencyKey: 'k1', bodyMd: '# t' },
});
process.env.FLEET_REQUIRE_FACTORY_TOKEN = '1';
// no token → 401
const noTok = await app.inject({
method: 'POST',
url: '/api/fleet/claim',
payload: { factoryId: 'fac_1', capabilities: [] },
});
expect(noTok.statusCode).toBe(401);
// out-of-scope productId (token is for lysnrai, request asks for 'other') → 403
const badPid = await app.inject({
method: 'POST',
url: '/api/fleet/claim',
headers: { 'x-factory-token': enrolled.token },
payload: { factoryId: 'fac_1', productId: 'other', capabilities: [] },
});
expect(badPid.statusCode).toBe(403);
// out-of-scope capability → 403
const badCap = await app.inject({
method: 'POST',
url: '/api/fleet/claim',
headers: { 'x-factory-token': enrolled.token },
payload: { factoryId: 'fac_1', capabilities: ['os:linux'] },
});
expect(badCap.statusCode).toBe(403);
// in-scope → ok (claim constrained to the token scope)
const ok = await app.inject({
method: 'POST',
url: '/api/fleet/claim',
headers: { 'x-factory-token': enrolled.token },
payload: { factoryId: 'fac_1', capabilities: [] },
});
expect(ok.statusCode).toBe(200);
expect(JSON.parse(ok.body).claimed).toBe(true);
});
it('enforcement ON: revoked token → 401 on heartbeat', async () => {
const app = await buildApp();
const enrolled = await enrollment.enrollFactory({
productId: PID,
factoryId: 'fac_1',
capabilities: [],
});
await enrollment.revokeToken({ productId: PID, factoryId: 'fac_1' });
process.env.FLEET_REQUIRE_FACTORY_TOKEN = '1';
const hb = await app.inject({
method: 'POST',
url: '/api/fleet/factories/heartbeat',
headers: { 'x-factory-token': enrolled.token },
payload: { factoryId: 'fac_1' },
});
expect(hb.statusCode).toBe(401);
});
// ── Management routes ──
it('POST /fleet/factories/enroll returns a token once (201) and rotate/revoke work', async () => {
const app = await buildApp();
const enroll = await app.inject({
method: 'POST',
url: '/api/fleet/factories/enroll',
payload: { factoryId: 'fac_1', capabilities: ['os:mac'] },
});
expect(enroll.statusCode).toBe(201);
const body = JSON.parse(enroll.body);
expect(body.token).toMatch(/^flt_/);
expect(body.scope.capabilities).toEqual(['os:mac']);
const rotate = await app.inject({
method: 'POST',
url: '/api/fleet/factories/fac_1/token/rotate',
payload: {},
});
expect(rotate.statusCode).toBe(200);
expect(JSON.parse(rotate.body).token).toMatch(/^flt_/);
expect(JSON.parse(rotate.body).token).not.toBe(body.token);
const revoke = await app.inject({
method: 'POST',
url: '/api/fleet/factories/fac_1/token/revoke',
payload: {},
});
expect(revoke.statusCode).toBe(200);
expect(JSON.parse(revoke.body).revoked).toBeGreaterThanOrEqual(1);
});
// ── Artifact review fixes ──
it('artifacts: list is productId-scoped (a foreign product sees none)', async () => {
const now = new Date().toISOString();
await repo.createArtifact({
id: 'art_mine',
productId: PID,
jobId: 'jX',
kind: 'log',
blobKey: 'fleet/lysnrai/jX/art_mine-log',
contentType: 'text/plain',
sizeBytes: 3,
createdAt: now,
});
await repo.createArtifact({
id: 'art_theirs',
productId: 'other-product',
jobId: 'jX',
kind: 'log',
blobKey: 'fleet/other/jX/art_theirs-log',
contentType: 'text/plain',
sizeBytes: 3,
createdAt: now,
});
const mine = await repo.listArtifactsByJob('jX', PID);
expect(mine).toHaveLength(1);
expect(mine[0].id).toBe('art_mine');
expect(await repo.listArtifactsByJob('jX', 'other-product')).toHaveLength(1);
expect(await repo.listArtifactsByJob('jX', 'nobody')).toHaveLength(0);
});
it('artifacts: upload ignores a spoofed body.productId (uses the request product)', async () => {
const app = await buildApp();
const sub = await app.inject({
method: 'POST',
url: '/api/fleet/jobs',
payload: { idempotencyKey: 'k1', bodyMd: '# t' },
});
const jobId = JSON.parse(sub.body).job.id as string;
const up = await app.inject({
method: 'POST',
url: `/api/fleet/jobs/${jobId}/artifacts`,
payload: {
productId: 'attacker-product',
kind: 'log',
contentType: 'text/plain',
contentBase64: Buffer.from('hello').toString('base64'),
},
});
expect(up.statusCode).toBe(201);
// the pointer is stored under the REQUEST product (lysnrai), not the spoofed one
expect(JSON.parse(up.body).artifact.productId).toBe(PID);
expect(await repo.listArtifactsByJob(jobId, PID)).toHaveLength(1);
expect(await repo.listArtifactsByJob(jobId, 'attacker-product')).toHaveLength(0);
});
});

View File

@ -0,0 +1,271 @@
/**
* Fleet factory enrollment + scoped rotatable tokens (§12 / §18 trust boundary).
*
* A factory authenticates to the coordinator with a SCOPED bearer credential
* (scope = {productId, factoryId, capabilities[]}). Tokens are stored HASHED at
* rest (sha256 the same primitive the auth module uses for verify/magic-link
* tokens); the high-entropy plaintext is returned exactly once at enroll/rotate
* and is NEVER persisted. Rotation keeps the previous token valid for a short
* grace overlap so an in-flight worker isn't cut off; revocation is immediate.
*
* Token-auth on the fleet endpoints is GATED by FLEET_REQUIRE_FACTORY_TOKEN
* (default OFF) so existing tests/behavior are unaffected until enforcement is on.
*/
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { FastifyRequest } from 'fastify';
import { ForbiddenError, UnauthorizedError } from '../../lib/errors.js';
import * as repo from './repository.js';
import {
FleetFactoryTokenDocSchema,
type FactoryTokenStatus,
type FleetFactoryDoc,
} from './types.js';
/** Header carrying the factory's scoped token (distinct from the user JWT). */
export const FACTORY_TOKEN_HEADER = 'x-factory-token';
const TOKEN_PREFIX = 'flt_';
const DEFAULT_ROTATION_OVERLAP_SEC = 300;
// ── Crypto helpers (reuse the auth module's sha256 token pattern; no new schemes) ──
function hashToken(plaintext: string): string {
return createHash('sha256').update(plaintext).digest('hex');
}
function newPlaintextToken(): string {
return `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
}
/** Constant-time compare of two hex digests of equal length. */
function constantTimeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length || a.length === 0) return false;
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}
function rotationOverlapSeconds(): number {
const v = Number(process.env.FLEET_TOKEN_ROTATION_OVERLAP_SEC);
return Number.isFinite(v) && v >= 0 ? v : DEFAULT_ROTATION_OVERLAP_SEC;
}
/** Enforcement flag — default OFF (unset). Read per-request so tests can toggle. */
export function factoryTokenEnforcementEnabled(): boolean {
const v = (process.env.FLEET_REQUIRE_FACTORY_TOKEN ?? '').trim().toLowerCase();
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}
// ── Scope + result shapes ───────────────────────────────────────────────────
export interface TokenScope {
tokenId: string;
factoryId: string;
productId: string;
capabilities: string[];
status: FactoryTokenStatus;
}
export interface IssuedToken {
tokenId: string;
/** Plaintext — returned ONCE, never persisted. */
token: string;
scope: { productId: string; factoryId: string; capabilities: string[] };
}
export interface EnrollResult extends IssuedToken {
factory: FleetFactoryDoc;
}
// ── Lifecycle ────────────────────────────────────────────────────────────────
export interface EnrollFactoryArgs {
productId: string;
factoryId?: string;
capabilities?: string[];
label?: string;
}
/**
* Enroll a factory (create/link its FleetFactoryDoc) and issue its first scoped
* token. Returns the plaintext token ONCE; only the hash is persisted.
*/
export async function enrollFactory(args: EnrollFactoryArgs): Promise<EnrollResult> {
const productId = args.productId;
const factoryId = args.factoryId ?? `fac_${randomUUID()}`;
const capabilities = args.capabilities ?? [];
const now = new Date().toISOString();
let factory = await repo.getFactory(factoryId, productId);
if (!factory) {
factory = await repo.upsertFactory({
id: factoryId,
productId,
factoryId,
descriptor: {},
capabilities,
health: 'ok',
load: 0,
seatLimit: 1,
lastHeartbeatAt: now,
});
}
const issued = await issueToken({ productId, factoryId, capabilities, label: args.label });
return { ...issued, factory };
}
interface IssueTokenArgs {
productId: string;
factoryId: string;
capabilities: string[];
label?: string;
}
async function issueToken(args: IssueTokenArgs): Promise<IssuedToken> {
const tokenId = `fltk_${randomUUID()}`;
const plaintext = newPlaintextToken();
const doc = FleetFactoryTokenDocSchema.parse({
id: tokenId,
productId: args.productId,
factoryId: args.factoryId,
tokenHash: hashToken(plaintext),
capabilities: args.capabilities,
label: args.label,
status: 'active',
createdAt: new Date().toISOString(),
});
await repo.createFactoryToken(doc);
return {
tokenId,
token: plaintext,
scope: {
productId: args.productId,
factoryId: args.factoryId,
capabilities: args.capabilities,
},
};
}
export interface RotateTokenArgs {
productId: string;
factoryId: string;
capabilities?: string[];
}
/**
* Issue a new active token for a factory and mark its current active token(s)
* `rotating` with a short grace overlap (so an in-flight worker keeps working
* until `rotatingUntil`). Capabilities default to the prior scope.
*/
export async function rotateToken(args: RotateTokenArgs): Promise<IssuedToken> {
const { productId, factoryId } = args;
const existing = await repo.listFactoryTokens(productId, factoryId);
const active = existing.filter(t => t.status === 'active');
const capabilities = args.capabilities ?? active[0]?.capabilities ?? [];
const rotatingUntil = new Date(Date.now() + rotationOverlapSeconds() * 1000).toISOString();
for (const t of active) {
await repo.updateFactoryToken(t.id, productId, { status: 'rotating', rotatingUntil });
}
return issueToken({ productId, factoryId, capabilities });
}
export interface RevokeTokenArgs {
productId: string;
factoryId?: string;
tokenId?: string;
}
/** Revoke a factory's token(s) — immediate rejection. Returns how many were revoked. */
export async function revokeToken(args: RevokeTokenArgs): Promise<number> {
const { productId } = args;
let targets = [] as Awaited<ReturnType<typeof repo.listFactoryTokens>>;
if (args.tokenId) {
const one = await repo.getFactoryToken(args.tokenId, productId);
if (one) targets = [one];
} else if (args.factoryId) {
targets = await repo.listFactoryTokens(productId, args.factoryId);
}
let revoked = 0;
for (const t of targets) {
if (t.status !== 'revoked') {
await repo.updateFactoryToken(t.id, productId, { status: 'revoked' });
revoked += 1;
}
}
return revoked;
}
/**
* Resolve a plaintext token to its scope, or null. Constant-time hash compare;
* revoked null; `rotating` accepted only within the grace window. Updates
* `lastUsedAt` best-effort on success.
*/
export async function verifyToken(plaintext: string | undefined): Promise<TokenScope | null> {
if (!plaintext) return null;
const hash = hashToken(plaintext);
const doc = await repo.findFactoryTokenByHash(hash);
if (!doc) return null;
if (!constantTimeEqualHex(doc.tokenHash, hash)) return null;
if (doc.status === 'revoked') return null;
if (doc.status === 'rotating') {
const until = doc.rotatingUntil ? new Date(doc.rotatingUntil).getTime() : 0;
if (!until || until < Date.now()) return null;
}
await repo
.updateFactoryToken(doc.id, doc.productId, { lastUsedAt: new Date().toISOString() })
.catch(() => null);
return {
tokenId: doc.id,
factoryId: doc.factoryId,
productId: doc.productId,
capabilities: doc.capabilities,
status: doc.status,
};
}
// ── Gated request enforcement (heartbeat / claim) ─────────────────────────────
export function extractFactoryToken(req: FastifyRequest): string | undefined {
const h = req.headers[FACTORY_TOKEN_HEADER];
if (typeof h === 'string') return h;
if (Array.isArray(h)) return h[0];
return undefined;
}
export interface EnforceOptions {
productId: string;
/** Capabilities the request wants to use; must be a subset of the token scope. */
capabilities?: string[];
/** When set, the token must belong to this factory. */
factoryId?: string;
}
/**
* When enforcement is ON: verify the factory token and confirm it covers the
* requested product (+ capabilities + factory). Returns the verified scope so the
* caller can constrain the operation to it. Throws 401 (missing/invalid/revoked)
* or 403 (out of scope). When enforcement is OFF: returns null (behaves as today).
*/
export async function enforceFactoryToken(
req: FastifyRequest,
opts: EnforceOptions
): Promise<TokenScope | null> {
if (!factoryTokenEnforcementEnabled()) return null;
const scope = await verifyToken(extractFactoryToken(req));
if (!scope) throw new UnauthorizedError('factory token required (missing, invalid, or revoked)');
if (scope.productId !== opts.productId) {
throw new ForbiddenError('factory token productId is out of scope');
}
if (opts.factoryId && scope.factoryId !== opts.factoryId) {
throw new ForbiddenError('factory token does not match the requested factoryId');
}
if (opts.capabilities && opts.capabilities.length > 0) {
const granted = new Set(scope.capabilities);
const missing = opts.capabilities.filter(c => !granted.has(c));
if (missing.length > 0) {
throw new ForbiddenError(
`factory token scope is missing capabilities: ${missing.join(', ')}`
);
}
}
return scope;
}

View File

@ -182,7 +182,7 @@ describe('fleet repository', () => {
sizeBytes: 42, sizeBytes: 42,
createdAt: now, createdAt: now,
}); });
const arts = await repo.listArtifactsByJob('j'); const arts = await repo.listArtifactsByJob('j', PID);
expect(arts).toHaveLength(1); expect(arts).toHaveLength(1);
expect(arts[0].blobKey).toBe('fleet/lysnrai/j/art_1-coverage'); expect(arts[0].blobKey).toBe('fleet/lysnrai/j/art_1-coverage');
@ -193,7 +193,7 @@ describe('fleet repository', () => {
// delete returns the removed doc and clears the partition // delete returns the removed doc and clears the partition
const removed = await repo.deleteArtifact('art_1', PID); const removed = await repo.deleteArtifact('art_1', PID);
expect(removed?.id).toBe('art_1'); expect(removed?.id).toBe('art_1');
expect(await repo.listArtifactsByJob('j')).toHaveLength(0); expect(await repo.listArtifactsByJob('j', PID)).toHaveLength(0);
expect(await repo.deleteArtifact('art_1', PID)).toBeNull(); expect(await repo.deleteArtifact('art_1', PID)).toBeNull();
}); });
}); });

View File

@ -22,6 +22,7 @@ import type {
FleetArtifactDoc, FleetArtifactDoc,
FleetEventDoc, FleetEventDoc,
FleetFactoryDoc, FleetFactoryDoc,
FleetFactoryTokenDoc,
FleetJobDoc, FleetJobDoc,
FleetLeaseDoc, FleetLeaseDoc,
FleetProfileDoc, FleetProfileDoc,
@ -52,6 +53,9 @@ function events(): DocumentCollection<FleetEventDoc> {
function artifacts(): DocumentCollection<FleetArtifactDoc> { function artifacts(): DocumentCollection<FleetArtifactDoc> {
return getCollection<FleetArtifactDoc>('fleet_artifacts', '/jobId'); return getCollection<FleetArtifactDoc>('fleet_artifacts', '/jobId');
} }
function factoryTokens(): DocumentCollection<FleetFactoryTokenDoc> {
return getCollection<FleetFactoryTokenDoc>('fleet_factory_tokens', '/productId');
}
/** Result of a compare-and-swap update. */ /** Result of a compare-and-swap update. */
export type RevResult<T> = { ok: true; doc: T } | { ok: false; reason: 'not_found' | 'conflict' }; export type RevResult<T> = { ok: true; doc: T } | { ok: false; reason: 'not_found' | 'conflict' };
@ -253,9 +257,16 @@ export async function createArtifact(doc: FleetArtifactDoc): Promise<FleetArtifa
return artifacts().create(doc); return artifacts().create(doc);
} }
/** All artifact pointers for a job, oldest-first (single partition — pk `/jobId`). */ /**
export async function listArtifactsByJob(jobId: string): Promise<FleetArtifactDoc[]> { * All artifact pointers for a job, oldest-first (single partition pk `/jobId`),
return artifacts().findMany({ filter: { jobId }, sort: { createdAt: 1 } }); * scoped to the owning `productId` so a caller can only list its own product's
* artifacts (a foreign product sees none).
*/
export async function listArtifactsByJob(
jobId: string,
productId: string
): Promise<FleetArtifactDoc[]> {
return artifacts().findMany({ filter: { jobId, productId }, sort: { createdAt: 1 } });
} }
/** /**
@ -283,3 +294,42 @@ export async function deleteArtifact(
await artifacts().delete(id, doc.jobId); await artifacts().delete(id, doc.jobId);
return doc; return doc;
} }
// ── Factory tokens (scoped, rotatable credentials — §12; hashed at rest) ────────
export async function createFactoryToken(doc: FleetFactoryTokenDoc): Promise<FleetFactoryTokenDoc> {
return factoryTokens().create(doc);
}
export async function getFactoryToken(
id: string,
productId: string
): Promise<FleetFactoryTokenDoc | null> {
return factoryTokens().findById(id, productId);
}
/** Resolve a token by its sha256 hash (verify path — partition-agnostic lookup). */
export async function findFactoryTokenByHash(
tokenHash: string
): Promise<FleetFactoryTokenDoc | null> {
const found = await factoryTokens().findMany({ filter: { tokenHash }, limit: 1 });
return found[0] ?? null;
}
/** All token records for a factory within a product (rotate/revoke lookups). */
export async function listFactoryTokens(
productId: string,
factoryId: string
): Promise<FleetFactoryTokenDoc[]> {
return factoryTokens().findMany({ filter: { productId, factoryId } });
}
export async function updateFactoryToken(
id: string,
productId: string,
updates: Partial<FleetFactoryTokenDoc>
): Promise<FleetFactoryTokenDoc | null> {
const cur = await factoryTokens().findById(id, productId);
if (!cur) return null;
return factoryTokens().update(id, productId, updates);
}

View File

@ -26,6 +26,7 @@ import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js'; import * as repo from './repository.js';
import * as coordinator from './coordinator.js'; import * as coordinator from './coordinator.js';
import * as artifactsBlob from './artifacts-blob.js'; import * as artifactsBlob from './artifacts-blob.js';
import * as enrollment from './enrollment.js';
import { import {
SubmitJobSchema, SubmitJobSchema,
ListJobsQuerySchema, ListJobsQuerySchema,
@ -35,6 +36,9 @@ import {
ReleaseLeaseSchema, ReleaseLeaseSchema,
HeartbeatSchema, HeartbeatSchema,
UploadArtifactSchema, UploadArtifactSchema,
EnrollFactorySchema,
RotateTokenSchema,
RevokeTokenSchema,
} from './types.js'; } from './types.js';
function badRequest(issues: { message: string }[]): never { function badRequest(issues: { message: string }[]): never {
@ -104,10 +108,18 @@ export async function fleetRoutes(app: FastifyInstance) {
const parsed = ClaimSchema.safeParse(req.body); const parsed = ClaimSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues); if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req); const pid = parsed.data.productId || getRequestProductId(req);
const claim = await coordinator.claimNextJob({ // §12: when enforcement is ON, the token must cover the productId + requested
// capabilities + factoryId; the claim is then CONSTRAINED to the verified scope
// (a factory cannot claim outside it). When OFF, behaves exactly as before.
const scope = await enrollment.enforceFactoryToken(req, {
productId: pid, productId: pid,
factoryId: parsed.data.factoryId,
capabilities: parsed.data.capabilities, capabilities: parsed.data.capabilities,
factoryId: parsed.data.factoryId,
});
const claim = await coordinator.claimNextJob({
productId: scope?.productId ?? pid,
factoryId: scope?.factoryId ?? parsed.data.factoryId,
capabilities: scope ? scope.capabilities : parsed.data.capabilities,
leaseSeconds: parsed.data.leaseSeconds, leaseSeconds: parsed.data.leaseSeconds,
}); });
if (!claim) return { claimed: false }; if (!claim) return { claimed: false };
@ -156,7 +168,14 @@ export async function fleetRoutes(app: FastifyInstance) {
await extractAuth(req); await extractAuth(req);
const parsed = HeartbeatSchema.safeParse(req.body); const parsed = HeartbeatSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues); if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req); const pidCandidate = parsed.data.productId || getRequestProductId(req);
// §12: gated token check (default OFF). When ON the token must cover this
// product + factory; the heartbeat is then bound to the verified scope.
const scope = await enrollment.enforceFactoryToken(req, {
productId: pidCandidate,
factoryId: parsed.data.factoryId,
});
const pid = scope?.productId ?? pidCandidate;
await coordinator.heartbeat({ await coordinator.heartbeat({
productId: pid, productId: pid,
factoryId: parsed.data.factoryId, factoryId: parsed.data.factoryId,
@ -192,7 +211,9 @@ export async function fleetRoutes(app: FastifyInstance) {
const { id: jobId } = req.params as { id: string }; const { id: jobId } = req.params as { id: string };
const parsed = UploadArtifactSchema.safeParse(req.body); const parsed = UploadArtifactSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues); if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req); // Review fix: the request/auth productId is authoritative — a spoofed
// body.productId must NOT override it (prevents writing into another product).
const pid = getRequestProductId(req);
const bytes = Buffer.from(parsed.data.contentBase64, 'base64'); const bytes = Buffer.from(parsed.data.contentBase64, 'base64');
if (bytes.length === 0) badRequest([{ message: 'contentBase64 decoded to empty bytes' }]); if (bytes.length === 0) badRequest([{ message: 'contentBase64 decoded to empty bytes' }]);
const { artifact, downloadUrl } = await artifactsBlob.uploadArtifact({ const { artifact, downloadUrl } = await artifactsBlob.uploadArtifact({
@ -207,11 +228,14 @@ export async function fleetRoutes(app: FastifyInstance) {
return { artifact, downloadUrl }; return { artifact, downloadUrl };
}); });
// ── Artifacts: list a job's pointers ── // ── Artifacts: list a job's pointers (productId-scoped) ──
app.get('/fleet/jobs/:id/artifacts', async req => { app.get('/fleet/jobs/:id/artifacts', async req => {
await extractAuth(req); await extractAuth(req);
const { id: jobId } = req.params as { id: string }; const { id: jobId } = req.params as { id: string };
const artifacts = await repo.listArtifactsByJob(jobId); // Review fix: scope to the request productId so a caller only sees its own
// product's artifact pointers for this job.
const pid = getRequestProductId(req);
const artifacts = await repo.listArtifactsByJob(jobId, pid);
return { artifacts }; return { artifacts };
}); });
@ -234,4 +258,59 @@ export async function fleetRoutes(app: FastifyInstance) {
if (!deleted) throw new NotFoundError('Artifact not found'); if (!deleted) throw new NotFoundError('Artifact not found');
return { deleted: true }; return { deleted: true };
}); });
// ── Enrollment + scoped tokens (§12) ──
// Management endpoints (user auth + productId). The plaintext token is returned
// ONCE here; only its hash is persisted.
app.post('/fleet/factories/enroll', async (req, reply) => {
await extractAuth(req);
const parsed = EnrollFactorySchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req);
const result = await enrollment.enrollFactory({
productId: pid,
factoryId: parsed.data.factoryId,
capabilities: parsed.data.capabilities,
label: parsed.data.label,
});
reply.code(201);
return {
factory: result.factory,
tokenId: result.tokenId,
token: result.token,
scope: result.scope,
};
});
// ── Rotate a factory's token (old token honored for a short grace overlap) ──
app.post('/fleet/factories/:id/token/rotate', async req => {
await extractAuth(req);
const { id: factoryId } = req.params as { id: string };
const parsed = RotateTokenSchema.safeParse(req.body ?? {});
if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req);
const factory = await repo.getFactory(factoryId, pid);
if (!factory) throw new NotFoundError('Factory not found');
const issued = await enrollment.rotateToken({
productId: pid,
factoryId,
capabilities: parsed.data.capabilities,
});
return { tokenId: issued.tokenId, token: issued.token, scope: issued.scope };
});
// ── Revoke a factory's token(s) — immediate rejection ──
app.post('/fleet/factories/:id/token/revoke', async req => {
await extractAuth(req);
const { id: factoryId } = req.params as { id: string };
const parsed = RevokeTokenSchema.safeParse(req.body ?? {});
if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req);
const revoked = await enrollment.revokeToken({
productId: pid,
factoryId,
tokenId: parsed.data.tokenId,
});
return { revoked };
});
} }

View File

@ -212,6 +212,32 @@ export const FleetFactoryDocSchema = z.object({
}); });
export type FleetFactoryDoc = z.infer<typeof FleetFactoryDocSchema>; export type FleetFactoryDoc = z.infer<typeof FleetFactoryDocSchema>;
export const FACTORY_TOKEN_STATUSES = ['active', 'rotating', 'revoked'] as const;
export type FactoryTokenStatus = (typeof FACTORY_TOKEN_STATUSES)[number];
/**
* FleetFactoryTokenDoc a SCOPED, rotatable credential for a factory (pk `/productId`, §12).
*
* Stored HASHED at rest (`tokenHash` = sha256 of the plaintext); the plaintext is
* returned exactly once at enroll/rotate and is NEVER persisted. `scope` is
* {productId (the partition), factoryId, capabilities[]}. `status` drives the trust
* decision: `active` accepted; `rotating` accepted only until `rotatingUntil` (grace
* overlap so an in-flight worker isn't cut off); `revoked` always rejected.
*/
export const FleetFactoryTokenDocSchema = z.object({
id: z.string(),
productId: z.string().min(1),
factoryId: z.string().min(1),
tokenHash: z.string().min(1),
capabilities: z.array(z.string()).default([]),
label: z.string().optional(),
status: z.enum(FACTORY_TOKEN_STATUSES).default('active'),
createdAt: z.string(),
lastUsedAt: z.string().optional(),
rotatingUntil: z.string().optional(),
});
export type FleetFactoryTokenDoc = z.infer<typeof FleetFactoryTokenDocSchema>;
/** FleetProfileDoc — an immutable, versioned profile snapshot (pk `/productId`). */ /** FleetProfileDoc — an immutable, versioned profile snapshot (pk `/productId`). */
export const FleetProfileDocSchema = z.object({ export const FleetProfileDocSchema = z.object({
id: z.string(), id: z.string(),
@ -343,3 +369,26 @@ export const UploadArtifactSchema = z.object({
contentBase64: z.string().min(1), contentBase64: z.string().min(1),
}); });
export type UploadArtifactInput = z.infer<typeof UploadArtifactSchema>; export type UploadArtifactInput = z.infer<typeof UploadArtifactSchema>;
/** Enroll a factory + issue its first scoped token (§12). */
export const EnrollFactorySchema = z.object({
productId: z.string().min(1).optional(),
factoryId: z.string().min(1).optional(),
capabilities: z.array(z.string()).default([]),
label: z.string().optional(),
});
export type EnrollFactoryInput = z.infer<typeof EnrollFactorySchema>;
/** Rotate a factory's token. Capabilities default to the current scope when omitted. */
export const RotateTokenSchema = z.object({
productId: z.string().min(1).optional(),
capabilities: z.array(z.string()).optional(),
});
export type RotateTokenInput = z.infer<typeof RotateTokenSchema>;
/** Revoke a factory's token(s); an explicit `tokenId` narrows to one credential. */
export const RevokeTokenSchema = z.object({
productId: z.string().min(1).optional(),
tokenId: z.string().min(1).optional(),
});
export type RevokeTokenInput = z.infer<typeof RevokeTokenSchema>;