diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index a0e93928..39d6ad19 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -195,6 +195,7 @@ const CONTAINER_DEFS: Record = { fleet_profiles: { partitionKeyPath: '/productId' }, fleet_events: { partitionKeyPath: '/jobId' }, fleet_artifacts: { partitionKeyPath: '/jobId' }, + fleet_factory_tokens: { partitionKeyPath: '/productId' }, }; export async function initCosmosIfNeeded(): Promise { diff --git a/services/platform-service/src/modules/fleet/artifacts.test.ts b/services/platform-service/src/modules/fleet/artifacts.test.ts index b96c1b22..d15daf10 100644 --- a/services/platform-service/src/modules/fleet/artifacts.test.ts +++ b/services/platform-service/src/modules/fleet/artifacts.test.ts @@ -124,8 +124,8 @@ describe('fleet artifacts — blob service', () => { contentType: 'image/png', }); - const a = await repo.listArtifactsByJob('jA'); - const b = await repo.listArtifactsByJob('jB'); + const a = await repo.listArtifactsByJob('jA', PID); + const b = await repo.listArtifactsByJob('jB', PID); expect(a).toHaveLength(2); expect(a.every(x => x.jobId === 'jA')).toBe(true); expect(b).toHaveLength(1); diff --git a/services/platform-service/src/modules/fleet/enrollment.test.ts b/services/platform-service/src/modules/fleet/enrollment.test.ts new file mode 100644 index 00000000..1f15f663 --- /dev/null +++ b/services/platform-service/src/modules/fleet/enrollment.test.ts @@ -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 { + 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); + }); +}); diff --git a/services/platform-service/src/modules/fleet/enrollment.ts b/services/platform-service/src/modules/fleet/enrollment.ts new file mode 100644 index 00000000..1c79fd06 --- /dev/null +++ b/services/platform-service/src/modules/fleet/enrollment.ts @@ -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 { + 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 { + 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 { + 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 { + const { productId } = args; + let targets = [] as Awaited>; + 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 { + 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 { + 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; +} diff --git a/services/platform-service/src/modules/fleet/repository.test.ts b/services/platform-service/src/modules/fleet/repository.test.ts index e7c393dc..039794a7 100644 --- a/services/platform-service/src/modules/fleet/repository.test.ts +++ b/services/platform-service/src/modules/fleet/repository.test.ts @@ -182,7 +182,7 @@ describe('fleet repository', () => { sizeBytes: 42, createdAt: now, }); - const arts = await repo.listArtifactsByJob('j'); + const arts = await repo.listArtifactsByJob('j', PID); expect(arts).toHaveLength(1); 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 const removed = await repo.deleteArtifact('art_1', PID); 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(); }); }); diff --git a/services/platform-service/src/modules/fleet/repository.ts b/services/platform-service/src/modules/fleet/repository.ts index a298855f..dfc95547 100644 --- a/services/platform-service/src/modules/fleet/repository.ts +++ b/services/platform-service/src/modules/fleet/repository.ts @@ -22,6 +22,7 @@ import type { FleetArtifactDoc, FleetEventDoc, FleetFactoryDoc, + FleetFactoryTokenDoc, FleetJobDoc, FleetLeaseDoc, FleetProfileDoc, @@ -52,6 +53,9 @@ function events(): DocumentCollection { function artifacts(): DocumentCollection { return getCollection('fleet_artifacts', '/jobId'); } +function factoryTokens(): DocumentCollection { + return getCollection('fleet_factory_tokens', '/productId'); +} /** Result of a compare-and-swap update. */ export type RevResult = { ok: true; doc: T } | { ok: false; reason: 'not_found' | 'conflict' }; @@ -253,9 +257,16 @@ export async function createArtifact(doc: FleetArtifactDoc): Promise { - return artifacts().findMany({ filter: { jobId }, sort: { createdAt: 1 } }); +/** + * All artifact pointers for a job, oldest-first (single partition — pk `/jobId`), + * 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 { + return artifacts().findMany({ filter: { jobId, productId }, sort: { createdAt: 1 } }); } /** @@ -283,3 +294,42 @@ export async function deleteArtifact( await artifacts().delete(id, doc.jobId); return doc; } + +// ── Factory tokens (scoped, rotatable credentials — §12; hashed at rest) ──────── + +export async function createFactoryToken(doc: FleetFactoryTokenDoc): Promise { + return factoryTokens().create(doc); +} + +export async function getFactoryToken( + id: string, + productId: string +): Promise { + return factoryTokens().findById(id, productId); +} + +/** Resolve a token by its sha256 hash (verify path — partition-agnostic lookup). */ +export async function findFactoryTokenByHash( + tokenHash: string +): Promise { + 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 { + return factoryTokens().findMany({ filter: { productId, factoryId } }); +} + +export async function updateFactoryToken( + id: string, + productId: string, + updates: Partial +): Promise { + const cur = await factoryTokens().findById(id, productId); + if (!cur) return null; + return factoryTokens().update(id, productId, updates); +} diff --git a/services/platform-service/src/modules/fleet/routes.ts b/services/platform-service/src/modules/fleet/routes.ts index 12b80706..3e6144ef 100644 --- a/services/platform-service/src/modules/fleet/routes.ts +++ b/services/platform-service/src/modules/fleet/routes.ts @@ -26,6 +26,7 @@ import { extractAuth } from '../../lib/auth.js'; import * as repo from './repository.js'; import * as coordinator from './coordinator.js'; import * as artifactsBlob from './artifacts-blob.js'; +import * as enrollment from './enrollment.js'; import { SubmitJobSchema, ListJobsQuerySchema, @@ -35,6 +36,9 @@ import { ReleaseLeaseSchema, HeartbeatSchema, UploadArtifactSchema, + EnrollFactorySchema, + RotateTokenSchema, + RevokeTokenSchema, } from './types.js'; function badRequest(issues: { message: string }[]): never { @@ -104,10 +108,18 @@ export async function fleetRoutes(app: FastifyInstance) { const parsed = ClaimSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); 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, - factoryId: parsed.data.factoryId, 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, }); if (!claim) return { claimed: false }; @@ -156,7 +168,14 @@ export async function fleetRoutes(app: FastifyInstance) { await extractAuth(req); const parsed = HeartbeatSchema.safeParse(req.body); 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({ productId: pid, factoryId: parsed.data.factoryId, @@ -192,7 +211,9 @@ export async function fleetRoutes(app: FastifyInstance) { const { id: jobId } = req.params as { id: string }; const parsed = UploadArtifactSchema.safeParse(req.body); 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'); if (bytes.length === 0) badRequest([{ message: 'contentBase64 decoded to empty bytes' }]); const { artifact, downloadUrl } = await artifactsBlob.uploadArtifact({ @@ -207,11 +228,14 @@ export async function fleetRoutes(app: FastifyInstance) { 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 => { await extractAuth(req); 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 }; }); @@ -234,4 +258,59 @@ export async function fleetRoutes(app: FastifyInstance) { if (!deleted) throw new NotFoundError('Artifact not found'); 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 }; + }); } diff --git a/services/platform-service/src/modules/fleet/types.ts b/services/platform-service/src/modules/fleet/types.ts index 5cc13e4b..a6eb5f86 100644 --- a/services/platform-service/src/modules/fleet/types.ts +++ b/services/platform-service/src/modules/fleet/types.ts @@ -212,6 +212,32 @@ export const FleetFactoryDocSchema = z.object({ }); export type FleetFactoryDoc = z.infer; +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; + /** FleetProfileDoc — an immutable, versioned profile snapshot (pk `/productId`). */ export const FleetProfileDocSchema = z.object({ id: z.string(), @@ -343,3 +369,26 @@ export const UploadArtifactSchema = z.object({ contentBase64: z.string().min(1), }); export type UploadArtifactInput = z.infer; + +/** 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; + +/** 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; + +/** 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;