feat(platform-service): fleet factory enrollment + scoped rotatable tokens (§12)
Adds factory enrollment + a scoped, rotatable credential model for the fleet
coordinator (trust boundary, §12/§18). 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 never persisted.
- enrollment.ts: enrollFactory (create/link factory + issue token), rotateToken
(new active token; prior marked `rotating` with a grace overlap so an in-flight
worker isn't cut off), revokeToken (immediate), verifyToken (constant-time hash
compare; revoked/expired-grace → null; updates lastUsedAt). Scope = {productId,
factoryId, capabilities[]}.
- Gated enforcement: enforceFactoryToken() on POST /fleet/factories/heartbeat and
POST /fleet/claim, active only when FLEET_REQUIRE_FACTORY_TOKEN is on (default
OFF — existing behavior/tests unchanged). When on: missing/invalid/revoked → 401;
out-of-scope productId/capability/factory → 403; and the claim is CONSTRAINED to
the verified token scope. Does not touch scheduler scoring or the claim CAS.
- types.ts: FleetFactoryTokenDoc + Enroll/Rotate/Revoke request schemas.
- repository.ts: fleet_factory_tokens collection + CRUD + findByHash.
- routes.ts (additive): POST /fleet/factories/enroll, /:id/token/rotate,
/:id/token/revoke (user auth + productId + Zod).
- cosmos-init.ts: register fleet_factory_tokens (/productId).
Also hardens the artifact routes (review fixes): listArtifactsByJob is now
productId-scoped (GET /fleet/jobs/:id/artifacts threads the request productId), and
artifact upload uses the request/auth productId authoritatively (a spoofed
body.productId no longer overrides it).
Tokens hashed at rest; plaintext shown once; no new crypto schemes; productId on
every doc; no any/console.log; enforcement default OFF.
This commit is contained in:
parent
e83ab9907e
commit
e06b730161
@ -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> {
|
||||||
|
|||||||
306
services/platform-service/src/modules/fleet/enrollment.test.ts
Normal file
306
services/platform-service/src/modules/fleet/enrollment.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
271
services/platform-service/src/modules/fleet/enrollment.ts
Normal file
271
services/platform-service/src/modules/fleet/enrollment.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user