From 8ca8c113943381523707c504a709b7c0fb672b18 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 15:07:23 -0700 Subject: [PATCH 1/5] Add tests for HTTP mint of workbench tenants and repo grants --- apps/hub/src/native-repo-grants.test.ts | 159 ++++++++++++++++++ .../test/provision-space-workbench.test.ts | 3 + packages/chat/test/routes.test.ts | 3 + .../test/workbench-tenancy-routes.test.ts | 1 + packages/chat/test/workbench-tenancy.test.ts | 121 ++++++++++++- .../test/workflow-participant-routes.test.ts | 3 + 6 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 apps/hub/src/native-repo-grants.test.ts diff --git a/apps/hub/src/native-repo-grants.test.ts b/apps/hub/src/native-repo-grants.test.ts new file mode 100644 index 000000000..4042ea31f --- /dev/null +++ b/apps/hub/src/native-repo-grants.test.ts @@ -0,0 +1,159 @@ +// Repo grants for GitHub start-reviewing go through native tenant HTTP, +// never a SQL insert into Interchange grant/role tables. +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { ApiCall } from "@workbench/hub-client"; + +let reportErrorCalls: unknown[] = []; +beforeEach(async () => { + reportErrorCalls = []; + await mock.module("@corbits/error-sink", () => ({ + reportError: (...args: unknown[]) => { + reportErrorCalls.push(args); + return "ref_test"; + }, + })); +}); +afterEach(() => { + mock.restore(); +}); + +const { hasRepoGrantViaHttp, mintRepoGrantViaHttp } = await import( + "./native-repo-grants" +); + +const REPO = { id: "1", name: "acme/widgets" }; +const TENANT_ID = "tnt_bench"; +const MEMBER_ROLE_ID = "rol_member"; +const COOKIES = ["session=alice"]; + +function rolesPage() { + return { + data: [ + { + id: MEMBER_ROLE_ID, + tenantId: TENANT_ID, + name: "member", + description: "System member role", + isSystem: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, + }; +} + +describe("mintRepoGrantViaHttp", () => { + test("POSTs /api/tenants/:id/grants for repo: read and never needs SQL", async () => { + const posts: { path: string; body: unknown; cookies: string[] | undefined }[] = + []; + const api: ApiCall = async (method, path, body, cookies) => { + if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/roles`)) { + return { status: 200, data: rolesPage(), cookies: cookies ?? [] }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { + posts.push({ path, body, cookies }); + return { status: 201, data: { id: "grt_1" }, cookies: cookies ?? [] }; + } + throw new Error(`unexpected ${method} ${path}`); + }; + + await mintRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES); + + expect(posts).toHaveLength(1); + expect(posts[0]?.cookies).toEqual(COOKIES); + expect(posts[0]?.body).toEqual({ + roleId: MEMBER_ROLE_ID, + resource: "repo:acme/widgets", + action: "read", + effect: "allow", + origin: "creator", + }); + expect(reportErrorCalls).toHaveLength(0); + }); + + test("reports and rethrows when POST /grants is rejected", async () => { + const api: ApiCall = async (method, path, _body, cookies) => { + if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/roles`)) { + return { status: 200, data: rolesPage(), cookies: cookies ?? [] }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { + return { + status: 403, + data: { error: { code: "forbidden", message: "nope" } }, + cookies: cookies ?? [], + }; + } + throw new Error(`unexpected ${method} ${path}`); + }; + + await expect( + mintRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES), + ).rejects.toThrow("POST /api/tenants/tnt_bench/grants failed with status 403"); + + expect(reportErrorCalls).toHaveLength(1); + expect(reportErrorCalls[0]).toEqual([ + expect.any(Error), + { + operation: "mintRepoGrant", + tenantId: TENANT_ID, + extra: { repo: "acme/widgets" }, + }, + ]); + }); +}); + +describe("hasRepoGrantViaHttp", () => { + test("is true when GET /grants already lists repo: read allow", async () => { + const api: ApiCall = async (method, path, _body, cookies) => { + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/grants?resource=`) + ) { + return { + status: 200, + data: { + data: [ + { + id: "grt_1", + tenantId: TENANT_ID, + roleId: MEMBER_ROLE_ID, + roleName: "member", + principalId: null, + principalName: null, + resource: "repo:acme/widgets", + action: "read", + effect: "allow", + conditions: null, + origin: "creator", + expiresAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, + }, + cookies: cookies ?? [], + }; + } + throw new Error(`unexpected ${method} ${path}`); + }; + + expect(await hasRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES)).toBe(true); + }); + + test("is false when GET /grants lists no matching row", async () => { + const api: ApiCall = async (method, path, _body, cookies) => { + if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/grants`)) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: cookies ?? [], + }; + } + throw new Error(`unexpected ${method} ${path}`); + }; + + expect(await hasRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES)).toBe(false); + }); +}); diff --git a/packages/chat/test/provision-space-workbench.test.ts b/packages/chat/test/provision-space-workbench.test.ts index e5615b02b..9e32c2fe6 100644 --- a/packages/chat/test/provision-space-workbench.test.ts +++ b/packages/chat/test/provision-space-workbench.test.ts @@ -23,6 +23,7 @@ describe("provisionSpaceWorkbench", () => { creatorPrincipalId: "prn_alice", creatorUserId: "usr_alice", name: "Morning digest", + cookies: ["session=test"], }, ); @@ -54,6 +55,7 @@ describe("provisionSpaceWorkbench", () => { creatorPrincipalId: "prn_alice", creatorUserId: "usr_alice", name: "Doomed space", + cookies: ["session=test"], }, ), ).rejects.toThrow("settings write failed"); @@ -75,6 +77,7 @@ describe("provisionSpaceWorkbench", () => { creatorPrincipalId: "prn_alice", creatorUserId: "usr_alice", name: "Undo me", + cookies: ["session=test"], }, ); diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 7fabc53a1..98b39c50b 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -595,6 +595,7 @@ describe("POST /workbenches — kind: chat + definitionId always find-or-reopens workbenchId: legacyWorkbenchId, name: "echo", creatorUserId: "prn_alice", + cookies: ["session=test"], }); await deps.store.createWorkbenchSettings({ tenantId: TENANT.id, @@ -638,6 +639,7 @@ describe("POST /workbenches — kind: chat + definitionId always find-or-reopens workbenchId: olderWorkbenchId, name: "echo", creatorUserId: "prn_alice", + cookies: ["session=test"], }); await deps.store.createWorkbenchSettings({ tenantId: TENANT.id, @@ -660,6 +662,7 @@ describe("POST /workbenches — kind: chat + definitionId always find-or-reopens workbenchId: newerWorkbenchId, name: "echo", creatorUserId: "prn_alice", + cookies: ["session=test"], }); await deps.store.createWorkbenchSettings({ tenantId: TENANT.id, diff --git a/packages/chat/test/workbench-tenancy-routes.test.ts b/packages/chat/test/workbench-tenancy-routes.test.ts index a0a45c879..c5d00e619 100644 --- a/packages/chat/test/workbench-tenancy-routes.test.ts +++ b/packages/chat/test/workbench-tenancy-routes.test.ts @@ -27,6 +27,7 @@ describe("POST /kinds", () => { workbenchId: "ins_general", name: "General", creatorUserId: "user_1", + cookies: ["session=test"], }, ); const app = mountAuthenticated(createWorkbenchTenancyRoutes({ tenancy })); diff --git a/packages/chat/test/workbench-tenancy.test.ts b/packages/chat/test/workbench-tenancy.test.ts index 2baec8ee3..d6cdfd637 100644 --- a/packages/chat/test/workbench-tenancy.test.ts +++ b/packages/chat/test/workbench-tenancy.test.ts @@ -8,7 +8,11 @@ // exercised here as an outcome of `moveWorkbenchTenancy` itself, never // as a separate pre-check call. import { expect, test } from "bun:test"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; +import type { ApiCall } from "@workbench/hub-client"; +import { + createInMemoryNativeTenantApi, + createInMemoryWorkbenchTenancyStore, +} from "../src/workbench-tenancy"; test("createWorkbenchTenant mints a tenant and records the parent link", async () => { const tenancy = createInMemoryWorkbenchTenancyStore(); @@ -18,6 +22,7 @@ test("createWorkbenchTenant mints a tenant and records the parent link", async ( workbenchId: "ins_general", name: "General", creatorUserId: "usr_alice", + cookies: ["session=test"], }); expect(result.tenantId).toMatch(/^tnt_/); @@ -49,18 +54,21 @@ test("listChildWorkbenchTenancies scopes strictly to the requested parent bench" workbenchId: "ins_a1", name: "A One", creatorUserId: "usr_alice", + cookies: ["session=test"], }); await tenancy.createWorkbenchTenant({ parentTenantId: "tnt_bench_a", workbenchId: "ins_a2", name: "A Two", creatorUserId: "usr_alice", + cookies: ["session=test"], }); await tenancy.createWorkbenchTenant({ parentTenantId: "tnt_bench_b", workbenchId: "ins_b1", name: "B One", creatorUserId: "usr_bob", + cookies: ["session=test"], }); const benchAWorkbenches = @@ -82,6 +90,7 @@ test("listWorkbenchTenantIds answers which requested ids are workbench tenancies workbenchId: "ins_general", name: "General", creatorUserId: "usr_alice", + cookies: ["session=test"], }); const result = await tenancy.listWorkbenchTenantIds([ @@ -105,12 +114,14 @@ test("moveWorkbenchTenancy re-parents one workbench without disturbing others wh workbenchId: "ins_moving", name: "Moving", creatorUserId: "usr_alice", + cookies: ["session=test"], }); await tenancy.createWorkbenchTenant({ parentTenantId: "tnt_bench_a", workbenchId: "ins_staying", name: "Staying", creatorUserId: "usr_alice", + cookies: ["session=test"], }); tenancy.registerExistingTenant("tnt_bench_c"); tenancy.grantManageInTenant("usr_alice", "tnt_bench_c"); @@ -140,6 +151,7 @@ test("moveWorkbenchTenancy reports a nonexistent destination tenant", async () = workbenchId: "ins_movable", name: "Movable", creatorUserId: "usr_alice", + cookies: ["session=test"], }); const outcome = await tenancy.moveWorkbenchTenancy({ @@ -158,6 +170,7 @@ test("moveWorkbenchTenancy is forbidden for a real destination tenant the caller workbenchId: "ins_movable", name: "Movable", creatorUserId: "usr_alice", + cookies: ["session=test"], }); tenancy.registerExistingTenant("tnt_bench_c"); @@ -177,12 +190,14 @@ test("moveWorkbenchTenancy treats the destination tenant a workbench was minted workbenchId: "ins_general", name: "General", creatorUserId: "usr_alice", + cookies: ["session=test"], }); await tenancy.createWorkbenchTenant({ parentTenantId: "tnt_bench_a", workbenchId: "ins_movable", name: "Movable", creatorUserId: "usr_alice", + cookies: ["session=test"], }); const outcome = await tenancy.moveWorkbenchTenancy({ @@ -194,30 +209,38 @@ test("moveWorkbenchTenancy treats the destination tenant a workbench was minted expect(outcome.kind).toBe("moved"); }); -test("compensateWorkbenchTenant removes the minted tenant and its tenancy link", async () => { +test("compensateWorkbenchTenant removes the tenancy link and leaves the native tenant", async () => { const tenancy = createInMemoryWorkbenchTenancyStore(); const minted = await tenancy.createWorkbenchTenant({ parentTenantId: "tnt_bench_a", workbenchId: "ins_orphaned", name: "Orphaned", creatorUserId: "usr_alice", + cookies: ["session=test"], }); await tenancy.createWorkbenchTenant({ parentTenantId: "tnt_bench_b", workbenchId: "ins_movable", name: "Movable", creatorUserId: "usr_bob", + cookies: ["session=test"], }); await tenancy.compensateWorkbenchTenant(minted.tenantId); expect(await tenancy.getWorkbenchTenancy("ins_orphaned")).toBeUndefined(); + expect(await tenancy.listWorkbenchTenantIds([minted.tenantId])).toEqual( + new Set(), + ); + // Native tenant rows stay — compensation is not a DELETE of Interchange + // tenants — so the compensated tenant is still a real move destination + // for a caller who holds manage there (the mint's own creator). const outcome = await tenancy.moveWorkbenchTenancy({ workbenchId: "ins_movable", newParentTenantId: minted.tenantId, callerRefId: "usr_alice", }); - expect(outcome).toEqual({ kind: "destination_not_found" }); + expect(outcome.kind).toBe("moved"); }); test("moveWorkbenchTenancy rejects moving a workbench into its own tenant", async () => { @@ -227,6 +250,7 @@ test("moveWorkbenchTenancy rejects moving a workbench into its own tenant", asyn workbenchId: "ins_general", name: "General", creatorUserId: "usr_alice", + cookies: ["session=test"], }); // The creator holds a manage grant in its own tenant (seeded as // owner) — proving the rejection is structural, not authorization, @@ -249,6 +273,7 @@ test("moveWorkbenchTenancy rejects a multi-node cycle: moving a workbench into i workbenchId: "ins_parent", name: "Parent", creatorUserId: "usr_alice", + cookies: ["session=test"], }); const childWorkbench = await tenancy.createWorkbenchTenant({ // The child workbench's tenant is parented under the parent @@ -258,6 +283,7 @@ test("moveWorkbenchTenancy rejects a multi-node cycle: moving a workbench into i workbenchId: "ins_child", name: "Child", creatorUserId: "usr_alice", + cookies: ["session=test"], }); tenancy.grantManageInTenant("usr_alice", childWorkbench.tenantId); @@ -291,6 +317,7 @@ test("moveWorkbenchTenancy allows moving a workbench into an unrelated tenant th workbenchId: "ins_movable", name: "Movable", creatorUserId: "usr_alice", + cookies: ["session=test"], }); // "tnt_sibling" shares an ancestor ("tnt_root") with the workbench's @@ -348,6 +375,7 @@ test("addWorkbenchMember mints a member-role principal in the workbench's own te workbenchId: "ins_general", name: "General", creatorUserId: "usr_alice", + cookies: ["session=test"], }); const result = await tenancy.addWorkbenchMember({ @@ -374,6 +402,7 @@ test("addWorkbenchMember is idempotent for a refId already holding a principal i workbenchId: "ins_general", name: "General", creatorUserId: "usr_alice", + cookies: ["session=test"], }); const first = await tenancy.addWorkbenchMember({ @@ -397,3 +426,89 @@ test("addWorkbenchMember returns undefined for a legacy workbench with no tenanc }), ).toBeUndefined(); }); + +test("createWorkbenchTenant mints via POST /api/tenants and POST grants, not SQL", async () => { + const calls: { + method: string; + path: string; + body: unknown; + cookies: string[] | undefined; + }[] = []; + const inner = createInMemoryNativeTenantApi(); + const api: ApiCall = async (method, path, body, cookies) => { + calls.push({ method, path, body, cookies }); + return inner(method, path, body, cookies); + }; + const tenancy = createInMemoryWorkbenchTenancyStore({ api }); + + const result = await tenancy.createWorkbenchTenant({ + parentTenantId: "tnt_bench_a", + workbenchId: "ins_general", + name: "General", + creatorUserId: "usr_alice", + cookies: ["session=alice"], + }); + + const tenantPosts = calls.filter( + (call) => call.method === "POST" && call.path === "/api/tenants", + ); + expect(tenantPosts).toHaveLength(1); + expect(tenantPosts[0]?.body).toEqual({ + name: "General", + slug: expect.stringContaining("general"), + parentId: "tnt_bench_a", + }); + expect(tenantPosts[0]?.cookies).toEqual(["session=alice"]); + + const grantPosts = calls.filter( + (call) => + call.method === "POST" && + call.path === `/api/tenants/${result.tenantId}/grants`, + ); + expect(grantPosts).toHaveLength(2); + expect(grantPosts.map((call) => call.body)).toEqual([ + { + roleId: expect.stringMatching(/^rol_/), + resource: "room:*", + action: "read", + effect: "allow", + origin: "creator", + }, + { + roleId: expect.stringMatching(/^rol_/), + resource: "room:*", + action: "write", + effect: "allow", + origin: "creator", + }, + ]); + expect( + calls.every( + (call) => + call.method !== "POST" || + call.path === "/api/tenants" || + /\/api\/tenants\/[^/]+\/grants$/.test(call.path), + ), + ).toBe(true); +}); + +test("createWorkbenchTenant fails closed when POST /api/tenants is rejected", async () => { + const api: ApiCall = async () => ({ + status: 403, + data: { error: { code: "forbidden", message: "nope" } }, + cookies: [], + }); + const tenancy = createInMemoryWorkbenchTenancyStore({ api }); + + await expect( + tenancy.createWorkbenchTenant({ + parentTenantId: "tnt_bench_a", + workbenchId: "ins_general", + name: "General", + creatorUserId: "usr_alice", + cookies: ["session=alice"], + }), + ).rejects.toThrow("POST /api/tenants failed with status 403"); + + expect(await tenancy.getWorkbenchTenancy("ins_general")).toBeUndefined(); +}); diff --git a/packages/chat/test/workflow-participant-routes.test.ts b/packages/chat/test/workflow-participant-routes.test.ts index 557240fce..b90eb4923 100644 --- a/packages/chat/test/workflow-participant-routes.test.ts +++ b/packages/chat/test/workflow-participant-routes.test.ts @@ -56,6 +56,9 @@ function buildApp( }), authenticator: overrides.authenticator ?? authenticateAsRun, tenancy: overrides.tenancy ?? createInMemoryWorkbenchTenancyStore(), + sessionFor: + overrides.sessionFor ?? + (async () => ["session=test"]), }) as unknown as Hono; } From 24a031055ebfddf2a39b43c0d97adc8877176300 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 15:07:27 -0700 Subject: [PATCH 2/5] Mint workbench tenants and repo grants via Interchange HTTP --- apps/hub/src/index.ts | 61 +-- apps/hub/src/native-repo-grants.test.ts | 84 +-- apps/hub/src/native-repo-grants.ts | 105 ++++ apps/hub/src/slack-tag-mount.ts | 13 + docs/workbench-tenancy.md | 37 +- packages/chat/src/routes.ts | 17 +- packages/chat/src/workbench-service.ts | 6 + packages/chat/src/workbench-tenancy.ts | 508 ++++++++++++------ .../chat/src/workflow-participant-routes.ts | 23 + .../test/workflow-participant-routes.test.ts | 4 +- .../src/connect-github-routes.ts | 28 +- .../src/connect-github-setup.ts | 6 +- 12 files changed, 606 insertions(+), 286 deletions(-) create mode 100644 apps/hub/src/native-repo-grants.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index a371572cd..9e31e6374 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -20,10 +20,8 @@ import { } from "@intx/db"; import { asset as assetTable, - grant as grantTable, model, modelPricing, - role as roleTable, tenant as tenantTable, workflowDefinition, } from "@intx/db/schema"; @@ -339,6 +337,10 @@ import { import { type } from "arktype"; import { betterAuth } from "better-auth"; import { createBenchSessionMinter } from "./bench-session"; +import { + hasRepoGrantViaHttp, + mintRepoGrantViaHttp, +} from "./native-repo-grants"; import { createSignInAttemptLimiter } from "./sign-in-rate-limit"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { type Context, Hono, type Next } from "hono"; @@ -1270,8 +1272,14 @@ export async function createHub(config: HubConfig) { // agents never produce text. `config.baseUrl` (not `localhost`) is // what makes the URL usable from a sidecar on another machine. app.route("/api/chat/noop-inference", createNoopInferenceRoutes()); + const selfApi = createHubAPI(config.baseUrl); + const sessionFor = createBenchSessionMinter({ + auth, + log: (line) => log.warn`${line}`, + }); const chatTenancy = createDrizzleWorkbenchTenancyStore(db, { conditionRegistry: chatConditionRegistry, + api: selfApi, }); // Mounted outside the tenant prefix, like `/api/onboarding`: the bench // switcher asks this across every tenant a signed-in user belongs to, @@ -1601,6 +1609,7 @@ export async function createHub(config: HubConfig) { turnQueue, authenticator: createWorkflowRunAuthenticator({ db }), tenancy: chatTenancy, + sessionFor, }), ); // Slack tag ingress (CL-5288 Phase 1): mounted OUTSIDE the tenant @@ -1618,6 +1627,7 @@ export async function createHub(config: HubConfig) { chatPlatform, roomMessages, chatTenancy, + sessionFor, workbenchSubscribers, turnQueue, }); @@ -2238,43 +2248,10 @@ export async function createHub(config: HubConfig) { }); return row?.id; }, - hasRepoGrant: async (tenantId, repo) => { - const existing = await db.query.grant.findFirst({ - where: and( - eq(grantTable.tenantId, tenantId), - eq(grantTable.resource, `repo:${repo.name}`), - eq(grantTable.action, "read"), - ), - columns: { id: true }, - }); - return existing !== undefined; - }, - mintRepoGrant: async (tenantId, repo) => { - const memberRole = await db.query.role.findFirst({ - where: and( - eq(roleTable.tenantId, tenantId), - eq(roleTable.name, "member"), - ), - columns: { id: true }, - }); - if (memberRole === undefined) { - throw new Error( - `tenant ${tenantId} has no system "member" role to scope a repo grant to`, - ); - } - const now = new Date(); - await db.insert(grantTable).values({ - id: generateId("grant"), - tenantId, - roleId: memberRole.id, - resource: `repo:${repo.name}`, - action: "read", - effect: "allow", - origin: "system", - createdAt: now, - updatedAt: now, - }); - }, + hasRepoGrant: (tenantId, repo, cookies) => + hasRepoGrantViaHttp(selfApi, tenantId, repo, cookies), + mintRepoGrant: (tenantId, repo, cookies) => + mintRepoGrantViaHttp(selfApi, tenantId, repo, cookies), createWebhookTrigger: async ( tenantId, principalId, @@ -3233,7 +3210,6 @@ export async function createHub(config: HubConfig) { // patching any vendor route. await applyAccessPolicyMigrations(config.databaseUrl); const accessPolicyStore = createDrizzleAccessPolicyStore(db); - const selfApi = createHubAPI(config.baseUrl); app.route( `${TENANT_PREFIX}/access-policy`, createAccessPolicyRoutes({ @@ -3261,10 +3237,7 @@ export async function createHub(config: HubConfig) { hubUrl: config.baseUrl, store: pendingSeedStore, pushWorkflow: createGitWorkflowPusher(), - sessionFor: createBenchSessionMinter({ - auth, - log: (line) => log.warn`${line}`, - }), + sessionFor, log: (line) => log.info`${line}`, logError: (line) => log.error`${line}`, }); diff --git a/apps/hub/src/native-repo-grants.test.ts b/apps/hub/src/native-repo-grants.test.ts index 4042ea31f..406e80e4e 100644 --- a/apps/hub/src/native-repo-grants.test.ts +++ b/apps/hub/src/native-repo-grants.test.ts @@ -1,25 +1,34 @@ // Repo grants for GitHub start-reviewing go through native tenant HTTP, // never a SQL insert into Interchange grant/role tables. -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { configureSync, resetSync } from "@intx/log"; import type { ApiCall } from "@workbench/hub-client"; -let reportErrorCalls: unknown[] = []; -beforeEach(async () => { - reportErrorCalls = []; - await mock.module("@corbits/error-sink", () => ({ - reportError: (...args: unknown[]) => { - reportErrorCalls.push(args); - return "ref_test"; +import { + hasRepoGrantViaHttp, + mintRepoGrantViaHttp, +} from "./native-repo-grants"; + +let records: { properties: Record }[]; + +function installCapturingSink(): void { + records = []; + configureSync({ + reset: true, + sinks: { + capture: (record) => { + records.push(record as { properties: Record }); + }, }, - })); -}); -afterEach(() => { - mock.restore(); -}); + loggers: [ + { category: ["errors"], sinks: ["capture"], lowestLevel: "debug" }, + { category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" }, + ], + }); +} -const { hasRepoGrantViaHttp, mintRepoGrantViaHttp } = await import( - "./native-repo-grants" -); +beforeEach(() => installCapturingSink()); +afterEach(() => resetSync()); const REPO = { id: "1", name: "acme/widgets" }; const TENANT_ID = "tnt_bench"; @@ -45,10 +54,16 @@ function rolesPage() { describe("mintRepoGrantViaHttp", () => { test("POSTs /api/tenants/:id/grants for repo: read and never needs SQL", async () => { - const posts: { path: string; body: unknown; cookies: string[] | undefined }[] = - []; + const posts: { + path: string; + body: unknown; + cookies: string[] | undefined; + }[] = []; const api: ApiCall = async (method, path, body, cookies) => { - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/roles`)) { + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/roles`) + ) { return { status: 200, data: rolesPage(), cookies: cookies ?? [] }; } if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { @@ -69,12 +84,15 @@ describe("mintRepoGrantViaHttp", () => { effect: "allow", origin: "creator", }); - expect(reportErrorCalls).toHaveLength(0); + expect(records).toHaveLength(0); }); test("reports and rethrows when POST /grants is rejected", async () => { const api: ApiCall = async (method, path, _body, cookies) => { - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/roles`)) { + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/roles`) + ) { return { status: 200, data: rolesPage(), cookies: cookies ?? [] }; } if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { @@ -89,17 +107,18 @@ describe("mintRepoGrantViaHttp", () => { await expect( mintRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES), - ).rejects.toThrow("POST /api/tenants/tnt_bench/grants failed with status 403"); + ).rejects.toThrow( + "POST /api/tenants/tnt_bench/grants failed with status 403", + ); - expect(reportErrorCalls).toHaveLength(1); - expect(reportErrorCalls[0]).toEqual([ - expect.any(Error), - { + expect(records).toHaveLength(1); + expect(records[0]?.properties).toEqual( + expect.objectContaining({ operation: "mintRepoGrant", tenantId: TENANT_ID, extra: { repo: "acme/widgets" }, - }, - ]); + }), + ); }); }); @@ -144,7 +163,10 @@ describe("hasRepoGrantViaHttp", () => { test("is false when GET /grants lists no matching row", async () => { const api: ApiCall = async (method, path, _body, cookies) => { - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/grants`)) { + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/grants`) + ) { return { status: 200, data: { data: [], nextCursor: null }, @@ -154,6 +176,8 @@ describe("hasRepoGrantViaHttp", () => { throw new Error(`unexpected ${method} ${path}`); }; - expect(await hasRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES)).toBe(false); + expect(await hasRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES)).toBe( + false, + ); }); }); diff --git a/apps/hub/src/native-repo-grants.ts b/apps/hub/src/native-repo-grants.ts new file mode 100644 index 000000000..5748ee28b --- /dev/null +++ b/apps/hub/src/native-repo-grants.ts @@ -0,0 +1,105 @@ +// Repo grants for GitHub start-reviewing: GET/POST the native +// `/api/tenants/:id/grants` surface as the requesting user. Never +// SQL-inserts Interchange grant/role rows. +import { GrantResponse, RoleResponse, paginatedSchema } from "@intx/types"; +import { parseAs, type ApiCall } from "@workbench/hub-client"; +import { reportError } from "@corbits/error-sink"; + +type RepoName = { name: string }; + +function throwHttp(label: string, status: number, data: unknown): never { + throw new Error( + `${label} failed with status ${status}: ${JSON.stringify(data)}`, + ); +} + +export async function hasRepoGrantViaHttp( + api: ApiCall, + tenantId: string, + repo: RepoName, + cookies: string[], +): Promise { + const resource = `repo:${repo.name}`; + const listed = await api( + "GET", + `/api/tenants/${tenantId}/grants?resource=${encodeURIComponent(resource)}&limit=200`, + undefined, + cookies, + ); + if (listed.status !== 200) { + throwHttp( + `GET /api/tenants/${tenantId}/grants`, + listed.status, + listed.data, + ); + } + const grants = parseAs( + paginatedSchema(GrantResponse), + listed.data, + "grants response", + ).data; + return grants.some( + (g) => + g.resource === resource && g.action === "read" && g.effect === "allow", + ); +} + +export async function mintRepoGrantViaHttp( + api: ApiCall, + tenantId: string, + repo: RepoName, + cookies: string[], +): Promise { + try { + const rolesResponse = await api( + "GET", + `/api/tenants/${tenantId}/roles?limit=200`, + undefined, + cookies, + ); + if (rolesResponse.status !== 200) { + throwHttp( + `GET /api/tenants/${tenantId}/roles`, + rolesResponse.status, + rolesResponse.data, + ); + } + const roles = parseAs( + paginatedSchema(RoleResponse), + rolesResponse.data, + "roles response", + ).data; + const memberRole = roles.find((r) => r.name === "member"); + if (memberRole === undefined) { + throw new Error( + `tenant ${tenantId} has no system "member" role to scope a repo grant to`, + ); + } + const posted = await api( + "POST", + `/api/tenants/${tenantId}/grants`, + { + roleId: memberRole.id, + resource: `repo:${repo.name}`, + action: "read", + effect: "allow", + origin: "creator", + }, + cookies, + ); + if (posted.status !== 201) { + throwHttp( + `POST /api/tenants/${tenantId}/grants`, + posted.status, + posted.data, + ); + } + } catch (cause) { + reportError(cause, { + operation: "mintRepoGrant", + tenantId, + extra: { repo: repo.name }, + }); + throw cause; + } +} diff --git a/apps/hub/src/slack-tag-mount.ts b/apps/hub/src/slack-tag-mount.ts index 2c3e25535..730eb1ecf 100644 --- a/apps/hub/src/slack-tag-mount.ts +++ b/apps/hub/src/slack-tag-mount.ts @@ -31,6 +31,7 @@ import { type ChatStore, type RoomMessageStore, } from "@corbits/chat"; +import type { SessionForUser } from "@workbench/onboarding"; import { createAutoProvisionPrincipalResolver, createDrizzleSlackChannelBindingStore, @@ -61,6 +62,7 @@ export type MountWorkbenchSlackTagDeps = { readonly chatPlatform: ChatPlatform; readonly roomMessages: RoomMessageStore; readonly chatTenancy: Pick; + readonly sessionFor: SessionForUser; readonly workbenchSubscribers: WorkbenchSubscriberRegistry; /** The same one-in-flight-turn-per-workbench queue `createChatRoutes` * is given (CL-6331) — shared, never a second instance, so a Slack @@ -141,6 +143,16 @@ export async function mountWorkbenchSlackTag( ); } + const cookies = await deps.sessionFor({ + userId: creatorPrincipal.refId, + tenantId: tenantRow.id, + }); + if (cookies === undefined) { + throw new Error( + `could not mint a session for Slack principal "${input.creatorPrincipalId}" (${creatorPrincipal.refId})`, + ); + } + const channelId = generateId("workflowRun"); const channelTenant = await deps.chatTenancy.createWorkbenchTenant({ @@ -148,6 +160,7 @@ export async function mountWorkbenchSlackTag( workbenchId: channelId, name: input.name, creatorUserId: creatorPrincipal.refId, + cookies, }); const preset = presetForKind("chat"); diff --git a/docs/workbench-tenancy.md b/docs/workbench-tenancy.md index a21b42832..b7d6a8911 100644 --- a/docs/workbench-tenancy.md +++ b/docs/workbench-tenancy.md @@ -25,21 +25,25 @@ notion of "which bench owns this workbench." 1. Mints a new tenant via `WorkbenchTenancyStore.createWorkbenchTenant` (`packages/chat/src/workbench-tenancy.ts`), parented under the calling - bench (`tenant.parentId = `). The child tenant is seeded - exactly as the native `POST /api/tenants` route seeds one it creates - directly — the same `owner`/`admin`/`member` system roles, the same - grant shapes — so it is indistinguishable from a tenant created by - hand through that route. The mint (tenant, its three system roles, - the creator's owner principal and role assignment, every system - grant, and the `workbench_tenancy` link) runs as one transaction, so it - either lands complete or not at all — there is no partially-seeded - tenant to observe. -2. Seeds the child tenant's `owner` principal for the creator's own auth - user id (`principal.refId`), so the creator is a first-class native - member of the workbench's own tenant, not just of the parent bench. + bench (`tenant.parentId = `). Native tenant/role/grant rows + are created through Interchange HTTP as the requesting user: + `POST /api/tenants` (name, slug, parentId) then + `POST /api/tenants/:id/grants` for the extra member-role `room:*` + read/write pair. Workbench never INSERT-s into `intx.grants`, + `intx.roles`, or `intx.tenants`. The native `POST /api/tenants` route + seeds the same `owner`/`admin`/`member` system roles and grant shapes + a bench created by hand through that route would get, and returns the + Interchange `tenantId` subsequent grant/repo calls thread in the URL + (`/api/tenants/:tenantId/...` — the hub-client equivalent of a tenant + header). +2. The creator becomes the child tenant's `owner` principal because + `POST /api/tenants` seeds the authenticated caller that way + (`principal.refId` is the creator's auth user id), so they are a + first-class native member of the workbench's own tenant, not just of + the parent bench. 3. Records the parent↔child link in `workbench_tenancy` (`packages/chat/src/schema.ts`), the table this package owns for - exactly this purpose. + exactly this purpose — the only SQL write on the mint path. 4. Launches the workbench host and stores `workbench_settings` / `workbench_launch` unchanged from before this feature: both remain scoped to the parent bench's tenant id, not the new child tenant. @@ -48,9 +52,10 @@ notion of "which bench owns this workbench." tenant if the launch fails: `POST .../chat/workbenches` wraps the launch call and, on failure, calls `WorkbenchTenancyStore.compensateWorkbenchTenant` to delete the - freshly-minted tenant (and everything cascaded onto it) before - re-raising the error. Both the failure and the compensation are - logged loudly. + `workbench_tenancy` link (native Interchange tenant rows stay — + compensation no longer deletes them) before re-raising the error. + Both the failure and the compensation are logged loudly. Mint HTTP + failures themselves go through `reportError`. Compensation is itself a database write, and can itself fail — the same outage that failed the launch, for instance, can just as diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index d4cd7de84..6cd3e6d3a 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -104,6 +104,7 @@ import { DefinitionProjectionMissingError, } from "@corbits/folded-runs"; import type { WorkbenchTenancyStore } from "./workbench-tenancy"; +import { cookiesFromHeader } from "@workbench/hub-client"; import type { AgentTurnStore } from "./agent-turns"; import type { ThreadStore } from "./threads"; import { ThreadDepthCapError } from "./threads"; @@ -1048,19 +1049,19 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { : undefined); // A workbench is a child tenant of the bench it is created in from - // the moment it exists. - // The mint itself is one transaction (see `workbench-tenancy.ts`), - // so it never lands half-seeded; but the agent mint that follows - // it is a separate transaction against separate tables, so a - // failure there is compensated for explicitly below rather than - // trusted to ordering alone. The creator becomes the child - // tenant's native owner exactly as the native tenant-creation - // route seeds its own creator (see `workbench-tenancy.ts`). + // the moment it exists. Native tenant/role/grant rows are minted + // through POST /api/tenants as this caller; the workbench_tenancy + // link is written after. A later launch failure compensates the + // link only — the native tenant stays, same as a later launch + // failure already lives with. The creator becomes the child + // tenant's native owner exactly as POST /api/tenants seeds its + // own creator. const workbenchTenant = await deps.tenancy.createWorkbenchTenant({ parentTenantId: tenant.id, workbenchId, name: chatTitle ?? workbenchId, creatorUserId: principal.refId, + cookies: cookiesFromHeader(c.req.header("cookie")), }); // Compensation can itself fail (a dropped connection, the same diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 65d5ee690..8dde9bda0 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -69,6 +69,8 @@ export type ProvisionSpaceWorkbenchInput = { readonly creatorPrincipalId: string; readonly creatorUserId: string; readonly name: string; + /** Request cookies forwarded into native `POST /api/tenants`. */ + readonly cookies: string[]; }; export type ProvisionSpaceWorkbenchResult = { @@ -100,6 +102,7 @@ export async function provisionSpaceWorkbench( workbenchId, name: input.name, creatorUserId: input.creatorUserId, + cookies: input.cookies, }); const preset = presetForKind("workbench"); @@ -176,6 +179,8 @@ export type MintAgentDmInput = { readonly callerPrincipalId: string; /** Human auth user `refId` — required by `createWorkbenchTenant`. */ readonly creatorUserId: string; + /** Request cookies forwarded into native `POST /api/tenants`. */ + readonly cookies: string[]; readonly definitionId: string; /** Optional title; else invitable description / definition name. */ readonly name?: string; @@ -322,6 +327,7 @@ export async function mintAgentDm( workbenchId, name: chatTitle ?? workbenchId, creatorUserId: input.creatorUserId, + cookies: input.cookies, }); async function compensateMint(err: unknown, phase: string): Promise { diff --git a/packages/chat/src/workbench-tenancy.ts b/packages/chat/src/workbench-tenancy.ts index 626490438..ae8e53595 100644 --- a/packages/chat/src/workbench-tenancy.ts +++ b/packages/chat/src/workbench-tenancy.ts @@ -5,14 +5,13 @@ // child-tenant listing or re-parenting route exists upstream (see // `docs/workbench-tenancy.md`). // -// Tenant/principal/role/grant creation here mirrors -// `vendor/intx/hub-api/src/routes/tenants.ts`'s `POST /api/tenants` -// exactly — same system roles, same owner grant shape — so a workbench -// tenant is indistinguishable, from the native surface's point of -// view, from one minted through that route by hand. This is -// consumption of `@intx/db`'s published schema, not a fork of the -// vendored route: nothing in `vendor/intx` is read or written by this -// module. +// Native tenants are minted through `POST /api/tenants` as the caller +// (the same path access-policy child-tenants and onboarding use). Extra +// member-role `room:*` grants are planted with `POST /api/tenants/:id/grants` +// using `origin: "creator"` — native POST records provenance-from-caller, +// not the `origin: "system"` SQL seed this module used to write. This +// module still reads native `tenant`/`principal`/`role`/`grant` rows for +// move and membership, and writes only `workbench_tenancy`. import { and, eq, gt, inArray, isNull, or } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { generateId } from "@intx/hub-common"; @@ -21,6 +20,14 @@ import { parseGrantRow } from "@intx/db"; import { evaluateGrants } from "@intx/authz"; import type { ConditionRegistry, GrantRule } from "@intx/authz"; import { getLogger } from "@intx/log"; +import { + paginatedSchema, + PrincipalResponse, + RoleResponse, + TenantResponse, +} from "@intx/types"; +import { reportError } from "@corbits/error-sink"; +import { parseAs, type ApiCall } from "@workbench/hub-client"; import { workbenchTenancy } from "./schema"; const log = getLogger(["chat", "workbench-tenancy"]); @@ -36,8 +43,6 @@ const log = getLogger(["chat", "workbench-tenancy"]); const MOVE_DESTINATION_RESOURCE = "workflow-run:*"; const MOVE_DESTINATION_ACTION = "manage"; -const SYSTEM_ROLES = ["owner", "admin", "member"] as const; - /** * The member role's room grant pair (CL-6332): every workbench tenant's * `member` role carries `room:*` read AND write from the moment it is @@ -80,6 +85,224 @@ function slugForWorkbenchTenant(name: string): string { return `${base !== "" ? base : "workbench"}-${tail}`; } +function throwHttp(label: string, status: number, data: unknown): never { + throw new Error( + `${label} failed with status ${status}: ${JSON.stringify(data)}`, + ); +} + +/** + * Mints a native child tenant through `POST /api/tenants` as the caller, + * then plants `MEMBER_ROOM_GRANTS` on the new tenant's `member` role. + * Returns the native tenant plus the owner's principal id in that tenant. + */ +async function mintNativeChildTenant( + api: ApiCall, + cookies: string[], + input: { + name: string; + slug: string; + parentTenantId: string; + creatorUserId: string; + }, +): Promise<{ + tenant: typeof TenantResponse.infer; + ownerPrincipalId: string; +}> { + const created = await api( + "POST", + "/api/tenants", + { + name: input.name, + slug: input.slug, + parentId: input.parentTenantId, + }, + cookies, + ); + if (created.status !== 201) { + throwHttp("POST /api/tenants", created.status, created.data); + } + const minted = parseAs(TenantResponse, created.data, "tenant response"); + + const principalsResponse = await api( + "GET", + `/api/tenants/${minted.id}/principals?limit=200`, + undefined, + cookies, + ); + if (principalsResponse.status !== 200) { + throwHttp( + `GET /api/tenants/${minted.id}/principals`, + principalsResponse.status, + principalsResponse.data, + ); + } + const principals = parseAs( + paginatedSchema(PrincipalResponse), + principalsResponse.data, + "principals response", + ); + const membership = principals.data.find( + (p) => p.refId === input.creatorUserId, + ); + if (membership === undefined) { + throw new Error( + `tenant ${minted.id} was created but has no principal for ${input.creatorUserId}`, + ); + } + + const rolesResponse = await api( + "GET", + `/api/tenants/${minted.id}/roles?limit=50`, + undefined, + cookies, + ); + if (rolesResponse.status !== 200) { + throwHttp( + `GET /api/tenants/${minted.id}/roles`, + rolesResponse.status, + rolesResponse.data, + ); + } + const roles = parseAs( + paginatedSchema(RoleResponse), + rolesResponse.data, + "roles response", + ); + const memberRole = roles.data.find((r) => r.name === "member"); + if (memberRole === undefined) { + throw new Error(`tenant ${minted.id} has no system "member" role`); + } + + // Native POST records provenance-from-caller (`origin: "creator"`), + // not the `origin: "system"` SQL this mint used to write. + for (const seed of MEMBER_ROOM_GRANTS) { + const posted = await api( + "POST", + `/api/tenants/${minted.id}/grants`, + { + roleId: memberRole.id, + resource: seed.resource, + action: seed.action, + effect: "allow", + origin: "creator", + }, + cookies, + ); + if (posted.status !== 201) { + throwHttp( + `POST /api/tenants/${minted.id}/grants ${seed.resource}/${seed.action}`, + posted.status, + posted.data, + ); + } + } + + return { tenant: minted, ownerPrincipalId: membership.id }; +} + +/** + * In-memory stand-in for `POST /api/tenants` + role list + grant plant, + * so unit tests exercise the same HTTP-shaped mint the drizzle store + * uses without pretending to SQL-insert native rows. + */ +export function createInMemoryNativeTenantApi(): ApiCall { + const minted: { + id: string; + name: string; + slug: string; + parentId?: string; + principalId: string; + memberRoleId: string; + }[] = []; + + return async (method, path, body, cookies = []) => { + if (method === "POST" && path === "/api/tenants") { + const parsed = body as { + name: string; + slug: string; + parentId?: string; + }; + const id = generateId("tenant"); + const principalId = generateId("principal"); + const memberRoleId = generateId("role"); + const row: (typeof minted)[number] = { + id, + name: parsed.name, + slug: parsed.slug, + principalId, + memberRoleId, + }; + if (parsed.parentId !== undefined) row.parentId = parsed.parentId; + minted.push(row); + const data: Record = { + id, + name: parsed.name, + slug: parsed.slug, + domain: `${parsed.slug}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + if (parsed.parentId !== undefined) data["parentId"] = parsed.parentId; + return { status: 201, data, cookies }; + } + const principalsMatch = /^\/api\/tenants\/([^/]+)\/principals/.exec(path); + if (method === "GET" && principalsMatch !== null) { + const tenantId = principalsMatch[1] ?? ""; + const row = minted.find((t) => t.id === tenantId); + const principalId = row?.principalId ?? generateId("principal"); + return { + status: 200, + data: { + data: [ + { + id: principalId, + tenantId, + kind: "user", + refId: "pending", + displayName: row?.name ?? "owner", + status: "active", + roles: [{ id: "rol_owner", name: "owner" }], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, + }, + cookies, + }; + } + const rolesMatch = /^\/api\/tenants\/([^/]+)\/roles/.exec(path); + if (method === "GET" && rolesMatch !== null) { + const tenantId = rolesMatch[1] ?? ""; + const row = minted.find((t) => t.id === tenantId); + const memberRoleId = row?.memberRoleId ?? generateId("role"); + return { + status: 200, + data: { + data: [ + { + id: memberRoleId, + tenantId, + name: "member", + description: "System member role", + isSystem: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, + }, + cookies, + }; + } + if (method === "POST" && /\/api\/tenants\/[^/]+\/grants$/.test(path)) { + return { status: 201, data: {}, cookies }; + } + throw new Error(`unexpected ${method} ${path}`); + }; +} + export type WorkbenchTenancyDb< TSchema extends Record = Record, > = PostgresJsDatabase; @@ -104,6 +327,8 @@ export interface CreateWorkbenchTenantInput { * `POST /api/tenants` seeds its creator. */ readonly creatorUserId: string; + /** The caller's session cookies, forwarded into native tenant/grant HTTP. */ + readonly cookies: string[]; } export interface CreateWorkbenchTenantResult { @@ -165,10 +390,9 @@ export interface TenantPrincipal { export interface WorkbenchTenancyStore { /** * Mints a native tenant as `input.workbenchId`'s own tenancy, parented - * under `input.parentTenantId`, seeds it exactly as the native - * tenant-creation route does (system roles, owner grant, admin/member - * grants, and an owner principal for `input.creatorUserId`), and - * records the link in `workbench_tenancy`. + * under `input.parentTenantId`, by calling `POST /api/tenants` as the + * requesting user, planting the extra member-role room grants, and + * recording the link in `workbench_tenancy`. */ createWorkbenchTenant( input: CreateWorkbenchTenantInput, @@ -228,13 +452,9 @@ export interface WorkbenchTenancyStore { /** * Undoes a mint that a subsequent step (the workbench host launch) - * failed to complete: deletes the `workbench_tenancy` link and the - * tenant row itself, which cascades to every row seeded alongside - * it (`role`, `principal`, `principal_role`, `grant`) through their - * own `onDelete: "cascade"` foreign keys to `tenant.id`. Leaves - * nothing behind for a workbench that never finished launching, - * rather than a fully-privileged tenant with no workbench pointing at - * it. + * failed to complete: deletes the `workbench_tenancy` link only. + * Native tenant/role/grant/principal rows stay — compensation no + * longer deletes Interchange tenants. */ compensateWorkbenchTenant(tenantId: string): Promise; @@ -316,12 +536,15 @@ export interface WorkbenchTenancyAuthzDeps { * (see `moveWorkbenchTenancy`). */ readonly conditionRegistry?: ConditionRegistry; + /** Self-HTTP caller used to mint native tenants and extra grants. */ + readonly api: ApiCall; } /** * The production `WorkbenchTenancyStore`, operating on `@intx/db`'s * native `tenant`/`principal`/`role`/`principalRole`/`grant` tables - * plus this package's own `workbench_tenancy` link table. + * (read/move/membership) plus this package's own `workbench_tenancy` + * link table. Native tenant minting is HTTP, never a SQL insert. */ export function createDrizzleWorkbenchTenancyStore< TSchema extends Record, @@ -331,149 +554,38 @@ export function createDrizzleWorkbenchTenancyStore< ): WorkbenchTenancyStore { return { async createWorkbenchTenant(input) { - const tenantId = generateId("tenant"); const slug = slugForWorkbenchTenant(input.name); - const domain = `${slug}.localhost`; - const now = new Date(); - const ownerPrincipalId = generateId("principal"); - - // Every insert below seeds one tenant's worth of native state — - // tenant, its three system roles, the creator's owner principal - // and role assignment, every system grant, and the chat-owned - // link row. A failure partway through must never leave a - // half-seeded tenant behind, so the whole mint runs as one - // transaction: it either lands complete or not at all. - await db.transaction(async (tx) => { - await tx.insert(tenant).values({ - id: tenantId, + let minted: Awaited>; + try { + minted = await mintNativeChildTenant(authz.api, input.cookies, { name: input.name, slug, - domain, - parentId: input.parentTenantId, - createdAt: now, - updatedAt: now, - }); - - const roleIds: Record<(typeof SYSTEM_ROLES)[number], string> = { - owner: "", - admin: "", - member: "", - }; - for (const roleName of SYSTEM_ROLES) { - const roleId = generateId("role"); - roleIds[roleName] = roleId; - await tx.insert(role).values({ - id: roleId, - tenantId, - name: roleName, - description: `System ${roleName} role`, - isSystem: true, - createdAt: now, - updatedAt: now, - }); - } - - await tx.insert(principal).values({ - id: ownerPrincipalId, - tenantId, - kind: "user", - refId: input.creatorUserId, - status: "active", - createdAt: now, - updatedAt: now, - }); - await tx.insert(principalRole).values({ - principalId: ownerPrincipalId, - roleId: roleIds.owner, - createdAt: now, - }); - - await tx.insert(grant).values({ - id: generateId("grant"), - tenantId, - roleId: roleIds.owner, - resource: "*", - action: "*", - effect: "allow", - origin: "system", - createdAt: now, - updatedAt: now, - }); - await tx.insert(grant).values([ - { - id: generateId("grant"), - tenantId, - roleId: roleIds.admin, - resource: "*", - action: "read", - effect: "allow" as const, - origin: "system" as const, - createdAt: now, - updatedAt: now, - }, - { - id: generateId("grant"), - tenantId, - roleId: roleIds.admin, - resource: "*", - action: "create", - effect: "allow" as const, - origin: "system" as const, - createdAt: now, - updatedAt: now, - }, - { - id: generateId("grant"), - tenantId, - roleId: roleIds.admin, - resource: "*", - action: "manage", - effect: "allow" as const, - origin: "system" as const, - createdAt: now, - updatedAt: now, - }, - ]); - await tx.insert(grant).values({ - id: generateId("grant"), - tenantId, - roleId: roleIds.member, - resource: "*", - action: "read", - effect: "allow", - origin: "system", - createdAt: now, - updatedAt: now, - }); - await tx.insert(grant).values( - MEMBER_ROOM_GRANTS.map((seed) => ({ - id: generateId("grant"), - tenantId, - roleId: roleIds.member, - resource: seed.resource, - action: seed.action, - effect: "allow" as const, - origin: "system" as const, - createdAt: now, - updatedAt: now, - })), - ); - - await tx.insert(workbenchTenancy).values({ - workbenchId: input.workbenchId, - tenantId, parentTenantId: input.parentTenantId, - slug, - createdAt: now, + creatorUserId: input.creatorUserId, + }); + } catch (cause) { + reportError(cause, { + operation: "createWorkbenchTenant", + tenantId: input.parentTenantId, + extra: { workbenchId: input.workbenchId }, }); + throw cause; + } + const now = new Date(); + await db.insert(workbenchTenancy).values({ + workbenchId: input.workbenchId, + tenantId: minted.tenant.id, + parentTenantId: input.parentTenantId, + slug: minted.tenant.slug, + createdAt: now, }); return { - tenantId, + tenantId: minted.tenant.id, parentTenantId: input.parentTenantId, - domain, - slug, - ownerPrincipalId, + domain: minted.tenant.domain, + slug: minted.tenant.slug, + ownerPrincipalId: minted.ownerPrincipalId, }; }, @@ -651,16 +763,12 @@ export function createDrizzleWorkbenchTenancyStore< async compensateWorkbenchTenant(tenantId) { log.error( - "Compensating workbench tenant {tenantId}: deleting the freshly " + - "minted tenant and its seeded rows after a downstream failure", + "Compensating workbench tenant {tenantId}: deleting the workbench_tenancy link after a downstream failure. Native tenant rows stay.", { tenantId }, ); - await db.transaction(async (tx) => { - await tx - .delete(workbenchTenancy) - .where(eq(workbenchTenancy.tenantId, tenantId)); - await tx.delete(tenant).where(eq(tenant.id, tenantId)); - }); + await db + .delete(workbenchTenancy) + .where(eq(workbenchTenancy.tenantId, tenantId)); }, async getTenantPrincipal(tenantId, principalId) { @@ -773,10 +881,10 @@ export function createDrizzleWorkbenchTenancyStore< /** * An in-memory `WorkbenchTenancyStore`, for tests and any host wiring - * chat routes without a database. Mints synthetic tenant/principal ids - * with the same `generateId` shape as the drizzle store, but performs - * no native-schema writes — the two stores share only their public - * contract, exercised by `test/workbench-tenancy.test.ts`. + * chat routes without a database. Mints native tenants through the + * injected `ApiCall` (defaulting to `createInMemoryNativeTenantApi`) + * and records only the chat-owned link — the same HTTP-shaped mint + * the drizzle store uses. * * The three extra methods below `WorkbenchTenancyStore` declares * (`registerExistingTenant`, `grantManageInTenant`, `registerPrincipal`) @@ -789,11 +897,41 @@ export function createDrizzleWorkbenchTenancyStore< * `"moved"`). `registerPrincipal` does the same for `getTenantPrincipal` * — standing up a fake native `principal` row without a database. */ -export function createInMemoryWorkbenchTenancyStore(): WorkbenchTenancyStore & { +export function createInMemoryWorkbenchTenancyStore( + deps: { + api?: ApiCall; + } = {}, +): WorkbenchTenancyStore & { registerExistingTenant(tenantId: string, parentTenantId?: string): void; grantManageInTenant(refId: string, tenantId: string): void; registerPrincipal(tenantId: string, principal: TenantPrincipal): void; } { + const inner = deps.api ?? createInMemoryNativeTenantApi(); + let pendingCreatorUserId = ""; + const api: ApiCall = async (method, path, body, cookies) => { + const result = await inner(method, path, body, cookies); + if ( + method === "GET" && + /\/api\/tenants\/[^/]+\/principals/.test(path) && + result.status === 200 && + result.data !== null && + typeof result.data === "object" && + "data" in result.data + ) { + const page = result.data as { data: Record[] }; + return { + ...result, + data: { + ...page, + data: page.data.map((row) => ({ + ...row, + refId: pendingCreatorUserId, + })), + }, + }; + } + return result; + }; const byWorkbenchId = new Map(); const existingTenants = new Set(); const manageGrants = new Set(); @@ -849,23 +987,40 @@ export function createInMemoryWorkbenchTenancyStore(): WorkbenchTenancyStore & { `Workbench "${input.workbenchId}" already has a tenancy`, ); } - const tenantId = generateId("tenant"); const slug = slugForWorkbenchTenant(input.name); + pendingCreatorUserId = input.creatorUserId; + let minted: Awaited>; + try { + minted = await mintNativeChildTenant(api, input.cookies, { + name: input.name, + slug, + parentTenantId: input.parentTenantId, + creatorUserId: input.creatorUserId, + }); + } catch (cause) { + reportError(cause, { + operation: "createWorkbenchTenant", + tenantId: input.parentTenantId, + extra: { workbenchId: input.workbenchId }, + }); + throw cause; + } + const tenantId = minted.tenant.id; const row: WorkbenchTenancyRow = { workbenchId: input.workbenchId, tenantId, parentTenantId: input.parentTenantId, - slug, + slug: minted.tenant.slug, createdAt: new Date(), }; byWorkbenchId.set(input.workbenchId, row); existingTenants.add(tenantId); parentOf.set(tenantId, input.parentTenantId); // The creator is minted as this tenant's owner, exactly as the - // drizzle store does — the owner role's `*`/`*` grant covers - // the move-destination check too. + // native POST /api/tenants route does — the owner role's `*`/`*` + // grant covers the move-destination check too. manageGrants.add(manageGrantKey(input.creatorUserId, tenantId)); - const ownerPrincipalId = generateId("principal"); + const ownerPrincipalId = minted.ownerPrincipalId; const ownerPrincipal: TenantPrincipal = { id: ownerPrincipalId, kind: "user", @@ -884,8 +1039,8 @@ export function createInMemoryWorkbenchTenancyStore(): WorkbenchTenancyStore & { return { tenantId, parentTenantId: input.parentTenantId, - domain: `${slug}.localhost`, - slug, + domain: minted.tenant.domain, + slug: minted.tenant.slug, ownerPrincipalId, }; }, @@ -911,7 +1066,6 @@ export function createInMemoryWorkbenchTenancyStore(): WorkbenchTenancyStore & { }, async compensateWorkbenchTenant(tenantId) { - existingTenants.delete(tenantId); for (const [workbenchId, row] of byWorkbenchId) { if (row.tenantId === tenantId) byWorkbenchId.delete(workbenchId); } diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index 2475e46e6..9d2a1f7f2 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -134,6 +134,14 @@ export type CreateWorkflowParticipantRoutesDeps = { | "getWorkbenchTenancy" | "getWorkbenchOwnerUserId" >; + /** + * Mints a session for the bench owner when a workflow child has no + * browser cookies. Production binds `createBenchSessionMinter`. + */ + readonly sessionFor: (args: { + userId: string; + tenantId: string; + }) => Promise; }; export function createWorkflowParticipantRoutes( @@ -273,6 +281,20 @@ export function createWorkflowParticipantRoutes( ); } + const cookies = await deps.sessionFor({ + userId: creatorUserId, + tenantId: ownerTenantId, + }); + if (cookies === undefined) { + return c.json( + errorEnvelope( + "session_unmintable", + `Could not mint a session for owner "${creatorUserId}" to create an agent DM`, + ), + 500, + ); + } + let minted: Awaited>; try { minted = await mintAgentDm( @@ -288,6 +310,7 @@ export function createWorkflowParticipantRoutes( callerWorkbenchId: workbench.workbenchId, callerPrincipalId: scope.principalId, creatorUserId, + cookies, definitionId: body.definitionId, }, ); diff --git a/packages/chat/test/workflow-participant-routes.test.ts b/packages/chat/test/workflow-participant-routes.test.ts index b90eb4923..6b43fb30e 100644 --- a/packages/chat/test/workflow-participant-routes.test.ts +++ b/packages/chat/test/workflow-participant-routes.test.ts @@ -56,9 +56,7 @@ function buildApp( }), authenticator: overrides.authenticator ?? authenticateAsRun, tenancy: overrides.tenancy ?? createInMemoryWorkbenchTenancyStore(), - sessionFor: - overrides.sessionFor ?? - (async () => ["session=test"]), + sessionFor: overrides.sessionFor ?? (async () => ["session=test"]), }) as unknown as Hono; } diff --git a/packages/workflow-catalog/src/connect-github-routes.ts b/packages/workflow-catalog/src/connect-github-routes.ts index c160a8036..4580d851e 100644 --- a/packages/workflow-catalog/src/connect-github-routes.ts +++ b/packages/workflow-catalog/src/connect-github-routes.ts @@ -25,7 +25,7 @@ import { Hono } from "hono"; import { type } from "arktype"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { makeErrorEnvelope } from "@workbench/hub-client"; +import { cookiesFromHeader, makeErrorEnvelope } from "@workbench/hub-client"; import { reportError } from "@corbits/error-sink"; import { @@ -88,11 +88,19 @@ export type ConnectGithubRoutesDeps = { * `./connect-github-setup.ts`'s `ConnectGithubSetupPorts.hasRepoGrant` * for why this makes a retry between minting the grant and creating * the trigger safe. */ - hasRepoGrant(tenantId: string, repo: GitHubRepoSummary): Promise; + hasRepoGrant( + tenantId: string, + repo: GitHubRepoSummary, + cookies: string[], + ): Promise; /** Mints the `repo:`-scoped grant a launched review run * needs to read this repo — see `./connect-github-setup.ts`'s * `ConnectGithubSetupPorts.mintRepoGrant` for the exact resource shape. */ - mintRepoGrant(tenantId: string, repo: GitHubRepoSummary): Promise; + mintRepoGrant( + tenantId: string, + repo: GitHubRepoSummary, + cookies: string[], + ): Promise; /** Creates the live `webhook_trigger` row this repo's pull-request-opened * events fire, scoped to the resolved code-review definition. A host * binds this to `@corbits/webhook-triggers`' `WebhookTriggerStore.create`. */ @@ -286,8 +294,18 @@ export function createConnectGithubRoutes( const introductionsAlreadyPosted = settingsBefore.selectedRepos.length > 0; const result = await startReviewingRepos(body.repoIds, state.repos, { - hasRepoGrant: (repo) => deps.hasRepoGrant(tenant.id, repo), - mintRepoGrant: (repo) => deps.mintRepoGrant(tenant.id, repo), + hasRepoGrant: (repo) => + deps.hasRepoGrant( + tenant.id, + repo, + cookiesFromHeader(c.req.header("cookie")), + ), + mintRepoGrant: (repo) => + deps.mintRepoGrant( + tenant.id, + repo, + cookiesFromHeader(c.req.header("cookie")), + ), createWebhookTrigger: (repo) => deps.createWebhookTrigger( tenant.id, diff --git a/packages/workflow-catalog/src/connect-github-setup.ts b/packages/workflow-catalog/src/connect-github-setup.ts index 6632fc580..9f8d54010 100644 --- a/packages/workflow-catalog/src/connect-github-setup.ts +++ b/packages/workflow-catalog/src/connect-github-setup.ts @@ -15,8 +15,8 @@ export interface ConnectGithubSetupPorts { * grant for a repo that already has one. The `grant` table * (`vendor/intx/db`) carries no unique constraint over * tenant/resource/action, so this read is the only thing standing - * between a retry and a duplicate row. A host binds this to a read - * against the same `grant` table `mintRepoGrant` inserts into. + * between a retry and a duplicate row. A host binds this to GET + * `/api/tenants/:id/grants?resource=repo:`. */ hasRepoGrant(repo: GitHubRepoSummary): Promise; /** @@ -24,7 +24,7 @@ export interface ConnectGithubSetupPorts { * name — the same `":"` resource-string shape * `idResource("room", "id")` builds in `@intx/hub-api`'s grant * middleware, applied here to a repo instead of a room). A host binds - * this to an actual `grant` row insert; this module never touches + * this to POST `/api/tenants/:id/grants`; this module never touches * drizzle directly. */ mintRepoGrant(repo: GitHubRepoSummary): Promise; From 82194a22ed968e12cf2519ded192319bba02b671 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 16:20:08 -0700 Subject: [PATCH 3/5] Add tests for Slack channel mint using the bench-owner session --- .../src/slack-channel-mint-session.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/hub/src/slack-channel-mint-session.test.ts diff --git a/apps/hub/src/slack-channel-mint-session.test.ts b/apps/hub/src/slack-channel-mint-session.test.ts new file mode 100644 index 000000000..7a2a26b46 --- /dev/null +++ b/apps/hub/src/slack-channel-mint-session.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { slackChannelMintSession } from "./slack-channel-mint-session"; + +const TENANT_ID = "tnt_bench"; +const OWNER_USER_ID = "user_owner"; +const OWNER_COOKIES = ["session=owner"]; + +describe("slackChannelMintSession", () => { + test("mints as the bench owner, never as a Slack member", async () => { + const sessionArgs: { userId: string; tenantId: string }[] = []; + const result = await slackChannelMintSession({ + tenantId: TENANT_ID, + getWorkbenchOwnerUserId: async (tenantId) => { + expect(tenantId).toBe(TENANT_ID); + return OWNER_USER_ID; + }, + sessionFor: async (args) => { + sessionArgs.push(args); + return OWNER_COOKIES; + }, + }); + + expect(result).toEqual({ + ownerUserId: OWNER_USER_ID, + cookies: OWNER_COOKIES, + }); + expect(sessionArgs).toEqual([ + { userId: OWNER_USER_ID, tenantId: TENANT_ID }, + ]); + }); + + test("fails closed when the bench has no owner user id", async () => { + await expect( + slackChannelMintSession({ + tenantId: TENANT_ID, + getWorkbenchOwnerUserId: async () => undefined, + sessionFor: async () => OWNER_COOKIES, + }), + ).rejects.toThrow(`no owner user id for tenant "${TENANT_ID}"`); + }); + + test("fails closed when the owner session cannot be minted", async () => { + await expect( + slackChannelMintSession({ + tenantId: TENANT_ID, + getWorkbenchOwnerUserId: async () => OWNER_USER_ID, + sessionFor: async () => undefined, + }), + ).rejects.toThrow( + `could not mint a session for bench owner "${OWNER_USER_ID}"`, + ); + }); +}); From e83c7cf1749ab4eb01c95eef397262b3a4d1d6d2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 16:20:43 -0700 Subject: [PATCH 4/5] Slack channel provision mints child tenants as the bench owner --- apps/hub/src/slack-channel-mint-session.ts | 29 ++++++++++++++++++++++ apps/hub/src/slack-tag-mount.ts | 20 ++++++++------- 2 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 apps/hub/src/slack-channel-mint-session.ts diff --git a/apps/hub/src/slack-channel-mint-session.ts b/apps/hub/src/slack-channel-mint-session.ts new file mode 100644 index 000000000..377048538 --- /dev/null +++ b/apps/hub/src/slack-channel-mint-session.ts @@ -0,0 +1,29 @@ +import type { SessionForUser } from "@workbench/onboarding"; + +/** + * Slack auto-provisioned identities are members. POST /api/tenants is + * guarded by tenancyCreation (default: owners), so channel mint must + * run as the bench owner — same pattern as agent-DM mint. + */ +export async function slackChannelMintSession(args: { + tenantId: string; + getWorkbenchOwnerUserId: (tenantId: string) => Promise; + sessionFor: SessionForUser; +}): Promise<{ ownerUserId: string; cookies: string[] }> { + const ownerUserId = await args.getWorkbenchOwnerUserId(args.tenantId); + if (ownerUserId === undefined) { + throw new Error( + `no owner user id for tenant "${args.tenantId}" — cannot mint a Slack channel workbench`, + ); + } + const cookies = await args.sessionFor({ + userId: ownerUserId, + tenantId: args.tenantId, + }); + if (cookies === undefined) { + throw new Error( + `could not mint a session for bench owner "${ownerUserId}" to provision a Slack channel`, + ); + } + return { ownerUserId, cookies }; +} diff --git a/apps/hub/src/slack-tag-mount.ts b/apps/hub/src/slack-tag-mount.ts index 730eb1ecf..4132502e8 100644 --- a/apps/hub/src/slack-tag-mount.ts +++ b/apps/hub/src/slack-tag-mount.ts @@ -19,6 +19,7 @@ import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { createMemoryState } from "@chat-adapter/state-memory"; import type { AppEnv } from "@intx/hub-api"; +import { reportError } from "@corbits/error-sink"; import { launchAndJoinAgent, @@ -32,6 +33,7 @@ import { type RoomMessageStore, } from "@corbits/chat"; import type { SessionForUser } from "@workbench/onboarding"; +import { slackChannelMintSession } from "./slack-channel-mint-session"; import { createAutoProvisionPrincipalResolver, createDrizzleSlackChannelBindingStore, @@ -61,7 +63,10 @@ export type MountWorkbenchSlackTagDeps = { >; readonly chatPlatform: ChatPlatform; readonly roomMessages: RoomMessageStore; - readonly chatTenancy: Pick; + readonly chatTenancy: Pick< + WorkbenchTenancyStore, + "createWorkbenchTenant" | "getWorkbenchOwnerUserId" + >; readonly sessionFor: SessionForUser; readonly workbenchSubscribers: WorkbenchSubscriberRegistry; /** The same one-in-flight-turn-per-workbench queue `createChatRoutes` @@ -143,15 +148,12 @@ export async function mountWorkbenchSlackTag( ); } - const cookies = await deps.sessionFor({ - userId: creatorPrincipal.refId, + const { ownerUserId, cookies } = await slackChannelMintSession({ tenantId: tenantRow.id, + getWorkbenchOwnerUserId: (id) => + deps.chatTenancy.getWorkbenchOwnerUserId(id), + sessionFor: deps.sessionFor, }); - if (cookies === undefined) { - throw new Error( - `could not mint a session for Slack principal "${input.creatorPrincipalId}" (${creatorPrincipal.refId})`, - ); - } const channelId = generateId("workflowRun"); @@ -159,7 +161,7 @@ export async function mountWorkbenchSlackTag( parentTenantId: tenantRow.id, workbenchId: channelId, name: input.name, - creatorUserId: creatorPrincipal.refId, + creatorUserId: ownerUserId, cookies, }); From 29ca9f4f8c095511a83f5f8619809f7c5639f86e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 16:41:39 -0700 Subject: [PATCH 5/5] Slack channel mint uses owner session and documents admin tenancy guard --- .../src/slack-channel-mint-session.test.ts | 128 +++++++++++++++++- apps/hub/src/slack-channel-mint-session.ts | 74 +++++++++- apps/hub/src/slack-tag-mount.ts | 38 +++--- apps/hub/test/tenant-create-guard.test.ts | 31 +++++ 4 files changed, 246 insertions(+), 25 deletions(-) diff --git a/apps/hub/src/slack-channel-mint-session.test.ts b/apps/hub/src/slack-channel-mint-session.test.ts index 7a2a26b46..c0e721eda 100644 --- a/apps/hub/src/slack-channel-mint-session.test.ts +++ b/apps/hub/src/slack-channel-mint-session.test.ts @@ -1,10 +1,38 @@ -import { describe, expect, test } from "bun:test"; -import { slackChannelMintSession } from "./slack-channel-mint-session"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { configureSync, resetSync } from "@intx/log"; +import { createInMemoryWorkbenchTenancyStore } from "@corbits/chat"; + +import { + mintSlackChannelWorkbench, + slackChannelMintSession, +} from "./slack-channel-mint-session"; const TENANT_ID = "tnt_bench"; const OWNER_USER_ID = "user_owner"; +const MEMBER_REF_ID = "user_slack_member"; const OWNER_COOKIES = ["session=owner"]; +let records: { properties: Record }[]; + +function installCapturingSink(): void { + records = []; + configureSync({ + reset: true, + sinks: { + capture: (record) => { + records.push(record as { properties: Record }); + }, + }, + loggers: [ + { category: ["errors"], sinks: ["capture"], lowestLevel: "debug" }, + { category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" }, + ], + }); +} + +beforeEach(() => installCapturingSink()); +afterEach(() => resetSync()); + describe("slackChannelMintSession", () => { test("mints as the bench owner, never as a Slack member", async () => { const sessionArgs: { userId: string; tenantId: string }[] = []; @@ -37,6 +65,7 @@ describe("slackChannelMintSession", () => { sessionFor: async () => OWNER_COOKIES, }), ).rejects.toThrow(`no owner user id for tenant "${TENANT_ID}"`); + expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); }); test("fails closed when the owner session cannot be minted", async () => { @@ -49,5 +78,100 @@ describe("slackChannelMintSession", () => { ).rejects.toThrow( `could not mint a session for bench owner "${OWNER_USER_ID}"`, ); + expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); + expect(records[0]?.properties.extra).toEqual({ + ownerUserId: OWNER_USER_ID, + }); + }); +}); + +describe("mintSlackChannelWorkbench", () => { + test("a member-role Slack identity provisions via the owner session", async () => { + const tenancy = createInMemoryWorkbenchTenancyStore(); + tenancy.registerExistingTenant(TENANT_ID); + tenancy.grantManageInTenant(OWNER_USER_ID, TENANT_ID); + + const mintCalls: { + creatorUserId: string; + cookies: string[]; + parentTenantId: string; + }[] = []; + const sessionArgs: { userId: string; tenantId: string }[] = []; + + const result = await mintSlackChannelWorkbench( + { + tenantId: TENANT_ID, + getWorkbenchOwnerUserId: (id) => tenancy.getWorkbenchOwnerUserId(id), + sessionFor: async (args) => { + sessionArgs.push(args); + if (args.userId === MEMBER_REF_ID) return ["session=member"]; + if (args.userId === OWNER_USER_ID) return OWNER_COOKIES; + return undefined; + }, + chatTenancy: { + createWorkbenchTenant: async (input) => { + mintCalls.push({ + creatorUserId: input.creatorUserId, + cookies: input.cookies, + parentTenantId: input.parentTenantId, + }); + return tenancy.createWorkbenchTenant(input); + }, + addWorkbenchMember: (input) => tenancy.addWorkbenchMember(input), + }, + }, + { name: "#eng", creatorRefId: MEMBER_REF_ID }, + ); + + expect(sessionArgs).toEqual([ + { userId: OWNER_USER_ID, tenantId: TENANT_ID }, + ]); + expect(mintCalls).toEqual([ + { + creatorUserId: OWNER_USER_ID, + cookies: OWNER_COOKIES, + parentTenantId: TENANT_ID, + }, + ]); + expect(result.ownerUserId).toBe(OWNER_USER_ID); + expect(result.channelId.length).toBeGreaterThan(0); + + const member = await tenancy.getTenantPrincipalByRefId( + result.workbenchTenantId, + MEMBER_REF_ID, + ); + expect(member?.refId).toBe(MEMBER_REF_ID); + }); + + test("fails closed without minting when the owner session cannot be minted", async () => { + const mintCalls: unknown[] = []; + const memberCalls: unknown[] = []; + + await expect( + mintSlackChannelWorkbench( + { + tenantId: TENANT_ID, + getWorkbenchOwnerUserId: async () => OWNER_USER_ID, + sessionFor: async () => undefined, + chatTenancy: { + createWorkbenchTenant: async (input) => { + mintCalls.push(input); + throw new Error("should not mint"); + }, + addWorkbenchMember: async (input) => { + memberCalls.push(input); + return undefined; + }, + }, + }, + { name: "#eng", creatorRefId: MEMBER_REF_ID }, + ), + ).rejects.toThrow( + `could not mint a session for bench owner "${OWNER_USER_ID}"`, + ); + + expect(mintCalls).toEqual([]); + expect(memberCalls).toEqual([]); + expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); }); }); diff --git a/apps/hub/src/slack-channel-mint-session.ts b/apps/hub/src/slack-channel-mint-session.ts index 377048538..7b4df3f01 100644 --- a/apps/hub/src/slack-channel-mint-session.ts +++ b/apps/hub/src/slack-channel-mint-session.ts @@ -1,3 +1,6 @@ +import { generateId } from "@intx/hub-common"; +import { reportError } from "@corbits/error-sink"; +import type { WorkbenchTenancyStore } from "@corbits/chat"; import type { SessionForUser } from "@workbench/onboarding"; /** @@ -12,18 +15,85 @@ export async function slackChannelMintSession(args: { }): Promise<{ ownerUserId: string; cookies: string[] }> { const ownerUserId = await args.getWorkbenchOwnerUserId(args.tenantId); if (ownerUserId === undefined) { - throw new Error( + const cause = new Error( `no owner user id for tenant "${args.tenantId}" — cannot mint a Slack channel workbench`, ); + reportError(cause, { + operation: "slack.provisionChannel", + tenantId: args.tenantId, + }); + throw cause; } const cookies = await args.sessionFor({ userId: ownerUserId, tenantId: args.tenantId, }); if (cookies === undefined) { - throw new Error( + const cause = new Error( `could not mint a session for bench owner "${ownerUserId}" to provision a Slack channel`, ); + reportError(cause, { + operation: "slack.provisionChannel", + tenantId: args.tenantId, + extra: { ownerUserId }, + }); + throw cause; } return { ownerUserId, cookies }; } + +export type MintSlackChannelWorkbenchDeps = { + readonly tenantId: string; + readonly getWorkbenchOwnerUserId: ( + tenantId: string, + ) => Promise; + readonly sessionFor: SessionForUser; + readonly chatTenancy: Pick< + WorkbenchTenancyStore, + "createWorkbenchTenant" | "addWorkbenchMember" + >; +}; + +export type MintSlackChannelWorkbenchInput = { + readonly name: string; + readonly creatorRefId: string; +}; + +/** + * Mints the child workbench as the bench owner, then adds the Slack + * member via the existing membership path so they can use the channel. + */ +export async function mintSlackChannelWorkbench( + deps: MintSlackChannelWorkbenchDeps, + input: MintSlackChannelWorkbenchInput, +): Promise<{ + readonly channelId: string; + readonly ownerUserId: string; + readonly workbenchTenantId: string; +}> { + const { ownerUserId, cookies } = await slackChannelMintSession({ + tenantId: deps.tenantId, + getWorkbenchOwnerUserId: deps.getWorkbenchOwnerUserId, + sessionFor: deps.sessionFor, + }); + + const channelId = generateId("workflowRun"); + const channelTenant = await deps.chatTenancy.createWorkbenchTenant({ + parentTenantId: deps.tenantId, + workbenchId: channelId, + name: input.name, + creatorUserId: ownerUserId, + cookies, + }); + + await deps.chatTenancy.addWorkbenchMember({ + workbenchId: channelId, + refId: input.creatorRefId, + }); + + return { + channelId, + ownerUserId, + workbenchTenantId: channelTenant.tenantId, + }; +} diff --git a/apps/hub/src/slack-tag-mount.ts b/apps/hub/src/slack-tag-mount.ts index 4132502e8..341b455ea 100644 --- a/apps/hub/src/slack-tag-mount.ts +++ b/apps/hub/src/slack-tag-mount.ts @@ -15,11 +15,9 @@ import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import type { DB } from "@intx/db"; import { principal, tenant } from "@intx/db/schema"; -import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { createMemoryState } from "@chat-adapter/state-memory"; import type { AppEnv } from "@intx/hub-api"; -import { reportError } from "@corbits/error-sink"; import { launchAndJoinAgent, @@ -33,7 +31,6 @@ import { type RoomMessageStore, } from "@corbits/chat"; import type { SessionForUser } from "@workbench/onboarding"; -import { slackChannelMintSession } from "./slack-channel-mint-session"; import { createAutoProvisionPrincipalResolver, createDrizzleSlackChannelBindingStore, @@ -41,6 +38,7 @@ import { resolveThreadState, applySlackTagMigrations, } from "@corbits/slack-tag"; +import { mintSlackChannelWorkbench } from "./slack-channel-mint-session"; const log = getLogger(["hub", "slack-tag"]); @@ -65,7 +63,7 @@ export type MountWorkbenchSlackTagDeps = { readonly roomMessages: RoomMessageStore; readonly chatTenancy: Pick< WorkbenchTenancyStore, - "createWorkbenchTenant" | "getWorkbenchOwnerUserId" + "createWorkbenchTenant" | "getWorkbenchOwnerUserId" | "addWorkbenchMember" >; readonly sessionFor: SessionForUser; readonly workbenchSubscribers: WorkbenchSubscriberRegistry; @@ -148,22 +146,20 @@ export async function mountWorkbenchSlackTag( ); } - const { ownerUserId, cookies } = await slackChannelMintSession({ - tenantId: tenantRow.id, - getWorkbenchOwnerUserId: (id) => - deps.chatTenancy.getWorkbenchOwnerUserId(id), - sessionFor: deps.sessionFor, - }); - - const channelId = generateId("workflowRun"); - - const channelTenant = await deps.chatTenancy.createWorkbenchTenant({ - parentTenantId: tenantRow.id, - workbenchId: channelId, - name: input.name, - creatorUserId: ownerUserId, - cookies, - }); + const minted = await mintSlackChannelWorkbench( + { + tenantId: tenantRow.id, + getWorkbenchOwnerUserId: (id) => + deps.chatTenancy.getWorkbenchOwnerUserId(id), + sessionFor: deps.sessionFor, + chatTenancy: deps.chatTenancy, + }, + { + name: input.name, + creatorRefId: creatorPrincipal.refId, + }, + ); + const channelId = minted.channelId; const preset = presetForKind("chat"); const settingsRow = await deps.chatStore.createWorkbenchSettings({ @@ -202,7 +198,7 @@ export async function mountWorkbenchSlackTag( { channelId, tenantId: tenantRow.id, - channelTenant: channelTenant.tenantId, + channelTenant: minted.workbenchTenantId, }, ); return { channelId }; diff --git a/apps/hub/test/tenant-create-guard.test.ts b/apps/hub/test/tenant-create-guard.test.ts index 979f049a6..e19aaf258 100644 --- a/apps/hub/test/tenant-create-guard.test.ts +++ b/apps/hub/test/tenant-create-guard.test.ts @@ -162,6 +162,37 @@ describe("guardedHubApp — bypass shape B: arbitrary parentId under a tenant th expect(created).toHaveLength(0); }); + test("an admin is denied by the default owners tenancyCreation policy", async () => { + const { app: nativeApp, created } = stubNativeApp(); + const deps = depsFor({ + user: { + id: "usr_admin", + email: "admin@acme.example", + emailVerified: true, + }, + resolveCallerRoleNames: async (tenantId, userId) => + tenantId === "tnt_acme" && userId === "usr_admin" + ? ["admin"] + : undefined, + }); + const wrapped = guardedHubApp(nativeApp, deps); + + const response = await wrapped.request("/api/tenants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Sub bench", + slug: "sub-bench", + parentId: "tnt_acme", + }), + }); + expect(response.status).toBe(403); + expect( + ((await response.json()) as { error: { code: string } }).error.code, + ).toBe("tenancy_creation_forbidden"); + expect(created).toHaveLength(0); + }); + test("owners-admins policy also accepts an admin", async () => { const { app: nativeApp, created } = stubNativeApp(); const deps = depsFor({