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
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,15 @@ HUB_STATIC_DIR=../web/dist
# for local dev/test.
# CREDENTIAL_ENCRYPTION_KEY=

# Dev/test-only opt-out of the CREDENTIAL_ENCRYPTION_KEY requirement above:
# Seals every principal's per-principal signing key at rest, under its own
# key so it rotates independently of CREDENTIAL_ENCRYPTION_KEY. 64 hex
# characters (32 bytes, AES-256): openssl rand -hex 32. Required for any
# real deployment — the hub refuses to boot without it. Leave unset and
# instead set ALLOW_PLAINTEXT_SECRETS below for local dev/test.
# PRINCIPAL_KEY_ENCRYPTION_KEY=

# Dev/test-only opt-out of the CREDENTIAL_ENCRYPTION_KEY and
# PRINCIPAL_KEY_ENCRYPTION_KEY requirements above:
# set to boot without a key, storing those secrets unencrypted with a boot
# warning. Uncommented here so a fresh checkout's `bun run dev` keeps
# working out of the box. Safe to inherit as-is: the hub refuses to boot
Expand Down
35 changes: 18 additions & 17 deletions VENDORED.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,11 @@ const HubEnv = type({
"CREDENTIAL_ENCRYPTION_KEY?": type(/^[0-9a-fA-F]{64}$/).describe(
"a 64-character hex-encoded 32-byte AES-256 key (openssl rand -hex 32) encrypting secrets at rest through Interchange's CredentialCipher seam — webhook-trigger signing secrets and onboarding's OAuth PKCE connect state; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback",
),
"PRINCIPAL_KEY_ENCRYPTION_KEY?": type(/^[0-9a-fA-F]{64}$/).describe(
"a 64-character hex-encoded 32-byte AES-256 key (openssl rand -hex 32) sealing every principal's per-principal signing key at rest through Interchange's principal-key store — deliberately separate from CREDENTIAL_ENCRYPTION_KEY so the two rotate independently; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback",
),
"ALLOW_PLAINTEXT_SECRETS?": type("'1' | 'true'").describe(
"dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY, storing secrets at rest unencrypted with a boot warning; refused unless BASE_URL is a loopback address, so a real deployment can never inherit it by accident",
"dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY or PRINCIPAL_KEY_ENCRYPTION_KEY, storing secrets and signing keys at rest unencrypted with a boot warning; refused unless BASE_URL is a loopback address, so a real deployment can never inherit it by accident",
),
"ALLOW_UNVERIFIED_EMAILS?": type("'1' | 'true'").describe(
"dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks and pending-invite redemption normally require emailVerified; never set this for a real deployment",
Expand Down Expand Up @@ -377,6 +380,8 @@ export type HubConfig = {
* tests/evals — never set this for a real deployment. */
readonly githubApiBaseUrl?: string;
readonly credentialEncryptionKeyHex?: string;
/** Seals per-principal signing keys at rest; see PRINCIPAL_KEY_ENCRYPTION_KEY. */
readonly principalKeyEncryptionKeyHex?: string;
/** Dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY. */
readonly allowPlaintextSecrets: boolean;
/** Dev/test-only opt-in to skip @workbench/access-policy's email-
Expand Down Expand Up @@ -743,6 +748,9 @@ export function readHubConfig(
hubConfig.githubApiBaseUrl = parsed.GITHUB_API_BASE_URL;
if (parsed.CREDENTIAL_ENCRYPTION_KEY !== undefined)
hubConfig.credentialEncryptionKeyHex = parsed.CREDENTIAL_ENCRYPTION_KEY;
if (parsed.PRINCIPAL_KEY_ENCRYPTION_KEY !== undefined)
hubConfig.principalKeyEncryptionKeyHex =
parsed.PRINCIPAL_KEY_ENCRYPTION_KEY;
if (parsed.HUB_SIDECAR_WEBSOCKET_URL !== undefined)
hubConfig.sidecarWebSocketUrl = parsed.HUB_SIDECAR_WEBSOCKET_URL;
if (sidecarProvisioners.defaultProvisionerId !== undefined)
Expand Down
89 changes: 78 additions & 11 deletions apps/hub/src/default-tenant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { and, eq } from "drizzle-orm";
import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url";
import { setupDatabase } from "../../../scripts/db-setup";
import { dbGate } from "../../../scripts/e2e/db-gate";
import { createDB } from "@intx/db";
import { createDB, createPrincipalKeyStore } from "@intx/db";
import { createNoopCredentialCipher } from "@intx/crypto";
import { grant, principal, principalRole, role, tenant } from "@intx/db/schema";
import { ensureDefaultTenant, type BootAdminAuth } from "./default-tenant";

Expand Down Expand Up @@ -74,6 +75,12 @@ describeIfDb("ensureDefaultTenant", () => {
password: decodeURIComponent(new URL(scratchUrl).password),
database: scratchDatabase,
});
// The dev/test principal-key store: unencrypted at rest, matching the
// ALLOW_PLAINTEXT_SECRETS posture of every other suite in this package.
const principalKeyStore = createPrincipalKeyStore({
db: db.db,
cipher: createNoopCredentialCipher(),
});

beforeAll(async () => {
const maintenanceUrl = new URL(scratchUrl);
Expand Down Expand Up @@ -119,7 +126,13 @@ describeIfDb("ensureDefaultTenant", () => {

test("creates the root tenant when absent, with a derived name, domain, and null parent", async () => {
const { auth } = fakeBootAuth();
const id = await ensureDefaultTenant(db.db, auth, ADMIN, "acme");
const id = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"acme",
principalKeyStore,
);
expect(id).toMatch(/^tnt_/);
const rows = await rowsFor("acme");
expect(rows).toHaveLength(1);
Expand All @@ -131,7 +144,13 @@ describeIfDb("ensureDefaultTenant", () => {

test("seeds the boot admin as an owner member of a freshly created root", async () => {
const { auth, calls } = fakeBootAuth();
const id = await ensureDefaultTenant(db.db, auth, ADMIN, "seeded-root");
const id = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"seeded-root",
principalKeyStore,
);

expect(calls.userCreated).toBe(1);
expect(calls.accountsLinked).toBe(1);
Expand Down Expand Up @@ -172,8 +191,20 @@ describeIfDb("ensureDefaultTenant", () => {

test("re-runs are a no-op: the tenant, roles, grants, and membership are not duplicated", async () => {
const { auth } = fakeBootAuth();
const first = await ensureDefaultTenant(db.db, auth, ADMIN, "acme");
const second = await ensureDefaultTenant(db.db, auth, ADMIN, "acme");
const first = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"acme",
principalKeyStore,
);
const second = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"acme",
principalKeyStore,
);

expect(second).toBe(first);
expect(await rowsFor("acme")).toHaveLength(1);
Expand Down Expand Up @@ -203,7 +234,13 @@ describeIfDb("ensureDefaultTenant", () => {
})
.onConflictDoNothing();
const { auth } = fakeBootAuth();
const id = await ensureDefaultTenant(db.db, auth, ADMIN, "bee-co");
const id = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"bee-co",
principalKeyStore,
);
expect(id).toBe(concurrentId);
expect(await rowsFor("bee-co")).toHaveLength(1);
});
Expand All @@ -218,7 +255,13 @@ describeIfDb("ensureDefaultTenant", () => {
parentId: null,
});
const { auth } = fakeBootAuth();
const id = await ensureDefaultTenant(db.db, auth, ADMIN, "unowned");
const id = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"unowned",
principalKeyStore,
);
expect(id).toBe("tnt_unowned");

const memberships = await membershipsFor(id);
Expand Down Expand Up @@ -254,7 +297,13 @@ describeIfDb("ensureDefaultTenant", () => {
});

const { auth } = fakeBootAuth();
const id = await ensureDefaultTenant(db.db, auth, ADMIN, "already");
const id = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"already",
principalKeyStore,
);
expect(id).toBe("tnt_already");

const memberships = await membershipsFor(id);
Expand Down Expand Up @@ -292,7 +341,13 @@ describeIfDb("ensureDefaultTenant", () => {
});

const { auth } = fakeBootAuth();
const id = await ensureDefaultTenant(db.db, auth, ADMIN, "foreign");
const id = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"foreign",
principalKeyStore,
);
expect(id).toBe("tnt_foreign");

const memberships = await membershipsFor(id);
Expand All @@ -306,7 +361,13 @@ describeIfDb("ensureDefaultTenant", () => {
const { auth, calls } = fakeBootAuth({
preexistingUserWithoutCredential: true,
});
await ensureDefaultTenant(db.db, auth, ADMIN, "cred-repair");
await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"cred-repair",
principalKeyStore,
);

expect(calls.userCreated).toBe(0);
expect(calls.accountsLinked).toBe(1);
Expand All @@ -330,7 +391,13 @@ describeIfDb("ensureDefaultTenant", () => {
});

const { auth } = fakeBootAuth();
const id = await ensureDefaultTenant(db.db, auth, ADMIN, "roleless");
const id = await ensureDefaultTenant(
db.db,
auth,
ADMIN,
"roleless",
principalKeyStore,
);
expect(id).toBe("tnt_roleless");

const memberships = await membershipsFor(id);
Expand Down
37 changes: 23 additions & 14 deletions apps/hub/src/default-tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@

import { and, eq } from "drizzle-orm";
import { generateId } from "@intx/hub-common";
import type { DB } from "@intx/db";
import {
createPrincipalStore,
type DB,
type PrincipalKeyStore,
} from "@intx/db";
import { grant, principal, principalRole, role, tenant } from "@intx/db/schema";

/**
Expand Down Expand Up @@ -210,6 +214,7 @@ async function ensureOwnerMembership(
tenantId: string,
adminUserId: string,
ownerRoleId: string,
principalKeyStore: PrincipalKeyStore,
): Promise<void> {
const adminMemberships = await db
.select({ id: principal.id })
Expand Down Expand Up @@ -252,18 +257,15 @@ async function ensureOwnerMembership(
if (userMemberships.length > 0) return;

const now = new Date();
await db
.insert(principal)
.values({
id: generateId("principal"),
tenantId,
kind: "user",
refId: adminUserId,
status: "active",
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing();
await createPrincipalStore(db, principalKeyStore).createIfAbsent({
id: generateId("principal"),
tenantId,
kind: "user",
refId: adminUserId,
status: "active",
createdAt: now,
updatedAt: now,
});

const created = await db
.select({ id: principal.id })
Expand Down Expand Up @@ -312,6 +314,7 @@ export async function ensureDefaultTenant(
auth: BootAdminAuth,
admin: { email: string; password: string },
slug: string,
principalKeyStore: PrincipalKeyStore,
): Promise<string> {
// The membership references the admin user, so the user exists first.
const adminUserId = await ensureAdminUser(auth, admin);
Expand Down Expand Up @@ -370,7 +373,13 @@ export async function ensureDefaultTenant(
await ensureSystemRoleGrants(db, tenantId, roleName, roleIds[roleName]);
}

await ensureOwnerMembership(db, tenantId, adminUserId, roleIds.owner);
await ensureOwnerMembership(
db,
tenantId,
adminUserId,
roleIds.owner,
principalKeyStore,
);

return tenantId;
}
55 changes: 55 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
createApprovalStore,
createDB,
createGrantStore,
createPrincipalKeyStore,
createPrincipalStore,
createSidecarAllocationStore,
createSignalCorrelationStore,
createWorkflowRunDispatchStore,
Expand Down Expand Up @@ -510,6 +512,52 @@ export function credentialCipherFrom(
);
}

/**
* Hub-boot mint of the per-principal signing-key store: seals every minted
* signing key's private seed under `PRINCIPAL_KEY_ENCRYPTION_KEY` — a key
* deliberately separate from `CREDENTIAL_ENCRYPTION_KEY` so the two rotate
* independently. An unset key hard-fails boot for the same reason the
* credential cipher does: a hub that forgets this variable must not
* silently persist private keys in the clear — unless
* `ALLOW_PLAINTEXT_SECRETS` opts into the identity no-op cipher with a
* boot warning, for dev/test only.
*/
export function principalKeyStoreFrom(
config: HubConfig,
db: ReturnType<typeof createDB>["db"],
log: ReturnType<typeof getLogger>,
) {
if (config.principalKeyEncryptionKeyHex === undefined) {
if (!config.allowPlaintextSecrets) {
throw new Error(
[
"PRINCIPAL_KEY_ENCRYPTION_KEY is not set.",
"It seals every principal's per-principal signing key at rest,",
"so the hub refuses to boot without it. Generate one and add it",
"to .env:",
"",
" openssl rand -hex 32",
"",
"For local dev/test only, set ALLOW_PLAINTEXT_SECRETS=1 instead to",
"boot with signing keys stored unencrypted; never do this for a",
"real deployment.",
].join("\n"),
);
}
log.warn`No PRINCIPAL_KEY_ENCRYPTION_KEY configured; per-principal signing keys will NOT be encrypted at rest. ALLOW_PLAINTEXT_SECRETS is set — expected in dev/test only, never for a real deployment.`;
return createPrincipalKeyStore({
db,
cipher: createNoopCredentialCipher(),
});
}
return createPrincipalKeyStore({
db,
cipher: createEnvKeyCredentialCipher(
Buffer.from(config.principalKeyEncryptionKeyHex, "hex"),
),
});
}

/**
* Hub-boot mint of the process-wide credential cipher: build from
* config, then runtime-tag the result. Missing or wrong-shape input
Expand Down Expand Up @@ -613,6 +661,9 @@ export async function createHub(config: HubConfig) {
// Built once, tagged, and shared by every secret-at-rest seam in this
// composition root — see `hubCredentialCipher`.
const credentialCipher = hubCredentialCipher(config, log);
// Per-principal signing keys are sealed under their own operator key —
// see `principalKeyStoreFrom`.
const principalKeyStore = principalKeyStoreFrom(config, db, log);

const auth = betterAuth({
baseURL: config.baseUrl,
Expand Down Expand Up @@ -691,6 +742,7 @@ export async function createHub(config: HubConfig) {
auth,
config.envCredentialPlantAdmin,
config.defaultTenantSlug,
principalKeyStore,
);
// Account-keyed sign-in rate limit (CL-6494) — see `sign-in-rate-limit.ts`
// for why this replaces better-auth's own IP-keyed sign-in enforcement
Expand Down Expand Up @@ -750,6 +802,7 @@ export async function createHub(config: HubConfig) {
// relies on to trigger a native multi-step deployment safely.
const mailTriggeredRunGrants = createMailTriggeredRunGrantsMaterializer({
db,
principalKeyStore,
grantStore: createGrantStore(db),
});
// Hoisted ahead of their other uses below (`mountMemory`'s neighbors,
Expand Down Expand Up @@ -1146,6 +1199,7 @@ export async function createHub(config: HubConfig) {
workflowAllocationService,
workflowDispatchService,
credentialCipher,
principalKeyStore,
// `@intx/hub-api`'s `GetSession` is a deliberately pluggable seam
// (its own doc comment: "so a third-party identity provider can be
// plugged in"), and this composition root owns it. `@corbits/chat`'s
Expand Down Expand Up @@ -1456,6 +1510,7 @@ export async function createHub(config: HubConfig) {
const chatTenancy = createDrizzleWorkbenchTenancyStore(db, {
conditionRegistry: chatConditionRegistry,
api: selfApi,
principalStore: createPrincipalStore(db, principalKeyStore),
});
// Mounted outside the tenant prefix, like `/api/onboarding`: the bench
// switcher asks this across every tenant a signed-in user belongs to,
Expand Down
Loading
Loading