Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 3 additions & 29 deletions apps/hub/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions apps/hub/src/workflow-run-authenticator.ts
Original file line number Diff line number Diff line change
@@ -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,
};
},
};
}
140 changes: 140 additions & 0 deletions e2e/hub/workflow-run-authenticator.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading