diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index 572914fd5..6337d56fa 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -13,7 +13,7 @@ import { tenant as tenantTable, } from "@intx/db/schema"; import { and, eq } from "drizzle-orm"; -import { createEnvKeyCredentialCipher, sha256 } from "@intx/crypto"; +import { createEnvKeyCredentialCipher } from "@intx/crypto"; import { hexDecode, hexEncode, type SidecarCapabilityRule } from "@intx/types"; import { createApp, @@ -113,10 +113,10 @@ import { type WorkflowArtifactEnv, } from "@corbits/artifacts"; import { artifactMatchesLibraryKindSegment, LIBRARY_KIND_SEGMENTS } from "./library-kind-segments"; -import type { DB } from "@intx/db"; -import { sidecar, workflowRun } from "@intx/db/schema"; +import { workflowRun } from "@intx/db/schema"; import path from "node:path"; import { migrateHub } from "./migrate"; +import { createWorkflowRunAuthenticator } from "./workflow-run-authenticator"; // The same condition registry `mountHubRoutes` builds by default when no // registry is supplied -- kept as one local constant so every Corbits @@ -126,32 +126,6 @@ const grantConditionRegistry: ConditionRegistry = { time_window: timeWindowEvaluator, }; -// The one concrete `WorkflowRunAuthenticator` every workflow-run-authenticated -// Corbits surface below takes structurally (`@corbits/artifacts`' -// `mountWorkflowArtifacts`): a sidecar bearer token + run address resolve to -// the tenant/principal/run it names. -function createWorkflowRunAuthenticator(deps: { db: DB["db"] }) { - return { - async resolve(token: string, runAddress: string) { - if (token === "" || runAddress === "") return null; - const tokenHash = await sha256(token); - const sidecarRow = await deps.db.query.sidecar.findFirst({ - where: eq(sidecar.tokenHashSha256, tokenHash), - }); - if (sidecarRow === undefined) return null; - const run = await deps.db.query.workflowRun.findFirst({ - where: eq(workflowRun.address, runAddress), - }); - if (run === undefined || run.principalId === null) return null; - return { - tenantId: run.tenantId, - principalId: run.principalId, - runId: run.id, - }; - }, - }; -} - function buildProcessSidecarProvisioner( hubDataDir: string, hubWebSocketUrl: string, diff --git a/apps/hub/src/workflow-run-authenticator.ts b/apps/hub/src/workflow-run-authenticator.ts new file mode 100644 index 000000000..4340fb3e8 --- /dev/null +++ b/apps/hub/src/workflow-run-authenticator.ts @@ -0,0 +1,32 @@ +import { createSidecarCredentialResolver } from "@intx/hub-sessions"; +import type { DB } from "@intx/db"; +import { eq } from "drizzle-orm"; +import { workflowRun } from "@intx/db/schema"; + +// The one concrete `WorkflowRunAuthenticator` every workflow-run-authenticated +// Corbits surface takes structurally (`@corbits/artifacts`' +// `mountWorkflowArtifacts`): a sidecar bearer token + run address resolve to +// the tenant/principal/run it names. The token must belong to a live +// allocation, and the run must be that allocation's anchor run or one of its +// children, in the allocation's tenant. +export function createWorkflowRunAuthenticator(deps: { db: DB["db"] }) { + const sidecars = createSidecarCredentialResolver({ db: deps.db }); + return { + async resolve(token: string, runAddress: string) { + if (token === "" || runAddress === "") return null; + const identity = await sidecars.resolve(token); + if (identity?.kind !== "allocated") return null; + const run = await deps.db.query.workflowRun.findFirst({ + where: eq(workflowRun.address, runAddress), + }); + if (run === undefined || run.principalId === null) return null; + if (run.tenantId !== identity.tenantId) return null; + if (run.anchorRunId !== identity.anchorRunId) return null; + return { + tenantId: run.tenantId, + principalId: run.principalId, + runId: run.id, + }; + }, + }; +} diff --git a/e2e/hub/workflow-run-authenticator.test.ts b/e2e/hub/workflow-run-authenticator.test.ts new file mode 100644 index 000000000..9301b0ca1 --- /dev/null +++ b/e2e/hub/workflow-run-authenticator.test.ts @@ -0,0 +1,140 @@ +// The run authenticator must bind a sidecar token to the run its allocation +// anchors, and only while that allocation is live. Rows are real, so the +// suite skips without a reachable DATABASE_URL. + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { sha256 } from "@intx/crypto"; +import { createDB, dropSchema, runMigrations } from "@intx/db"; +import { + principal, + sidecar, + sidecarAllocation, + tenant, + workflowDefinition, + workflowRun, +} from "@intx/db/schema"; +import { createWorkflowRunAuthenticator } from "../../apps/hub/src/workflow-run-authenticator"; +import { dbGate } from "../lib/db-gate"; + +const describeIfDb = dbGate(process.env["DATABASE_URL"] ?? "", import.meta.path); + +describeIfDb("workflow run authenticator", () => { + const url = new URL(process.env["DATABASE_URL"] ?? "postgres://x@localhost/x"); + // A dedicated schema keeps the suite independent of the shared database's + // migration level. + const config = { + host: url.hostname, + port: url.port === "" ? 5432 : Number(url.port), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database: url.pathname.replace(/^\//, ""), + schema: `run_auth_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`, + }; + const handle = createDB(config); + const { db } = handle; + const suffix = crypto.randomUUID().slice(0, 8); + + async function seedTenant(name: string) { + const id = `t-${name}-${suffix}`; + await db.insert(tenant).values({ + id, + name, + slug: `${name}-${suffix}`, + domain: `${name}-${suffix}.example.com`, + }); + return id; + } + + // One deployment: an anchor run, a child run, a live sidecar allocation. + async function seedDeployment(tenantId: string, name: string, status = "allocated") { + const definitionId = `d-${name}-${suffix}`; + const anchorId = `anchor-${name}-${suffix}`; + const principalId = `p-${name}-${suffix}`; + const token = `token-${name}-${suffix}`; + const sidecarId = `s-${name}-${suffix}`; + await db.insert(workflowDefinition).values({ + id: definitionId, + tenantId, + name, + }); + await db.insert(principal).values({ + id: principalId, + tenantId, + kind: "workflow", + refId: anchorId, + status: "active", + }); + await db.insert(workflowRun).values({ + id: anchorId, + definitionId, + anchorRunId: anchorId, + tenantId, + principalId, + address: `${name}-${suffix}@example.com`, + }); + await db.insert(workflowRun).values({ + id: `child-${name}-${suffix}`, + definitionId, + anchorRunId: anchorId, + tenantId, + principalId, + address: `child-${name}-${suffix}@example.com`, + }); + await db.insert(sidecar).values({ + id: sidecarId, + tokenHashSha256: Buffer.from(await sha256(token)), + }); + await db.insert(sidecarAllocation).values({ + id: `a-${name}-${suffix}`, + anchorRunId: anchorId, + tenantId, + provisionerId: "test", + provisionerApiVersion: 1, + provisionerBindingFingerprint: "test", + sidecarId, + status: status as "allocated", + }); + return { + token, + anchorAddress: `${name}-${suffix}@example.com`, + childAddress: `child-${name}-${suffix}@example.com`, + anchorId, + principalId, + }; + } + + const authenticator = createWorkflowRunAuthenticator({ db }); + let tenantId: string; + + beforeAll(async () => { + await runMigrations(config, { schema: config.schema }); + tenantId = await seedTenant("one"); + }, 120_000); + + afterAll(async () => { + await handle.close(); + await dropSchema(config, { schema: config.schema }); + }, 60_000); + + test("a token resolves the run its allocation anchors, and that run's children", async () => { + const a = await seedDeployment(tenantId, "own"); + expect(await authenticator.resolve(a.token, a.anchorAddress)).toEqual({ + tenantId, + principalId: a.principalId, + runId: a.anchorId, + }); + expect((await authenticator.resolve(a.token, a.childAddress))?.tenantId).toBe(tenantId); + }); + + test("a token for run A does not authenticate as run B", async () => { + const a = await seedDeployment(tenantId, "runa"); + const b = await seedDeployment(tenantId, "runb"); + expect(await authenticator.resolve(a.token, b.anchorAddress)).toBeNull(); + expect(await authenticator.resolve(a.token, b.childAddress)).toBeNull(); + }); + + test("a released allocation's token no longer authenticates", async () => { + const a = await seedDeployment(tenantId, "released", "released"); + expect(await authenticator.resolve(a.token, a.anchorAddress)).toBeNull(); + }); +});