Skip to content

Commit 732a80d

Browse files
Merge pull request #522 from corbitsdev/cl-7256-stop-writing-interchanges-grant-and-role-tables-directly
Mint workbench tenants and repo grants via Interchange HTTP
2 parents a4b2e54 + 29ca9f4 commit 732a80d

19 files changed

Lines changed: 1176 additions & 267 deletions

‎apps/hub/src/index.ts‎

Lines changed: 17 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,8 @@ import {
2020
} from "@intx/db";
2121
import {
2222
asset as assetTable,
23-
grant as grantTable,
2423
model,
2524
modelPricing,
26-
role as roleTable,
2725
tenant as tenantTable,
2826
workflowDefinition,
2927
} from "@intx/db/schema";
@@ -339,6 +337,10 @@ import {
339337
import { type } from "arktype";
340338
import { betterAuth } from "better-auth";
341339
import { createBenchSessionMinter } from "./bench-session";
340+
import {
341+
hasRepoGrantViaHttp,
342+
mintRepoGrantViaHttp,
343+
} from "./native-repo-grants";
342344
import { createSignInAttemptLimiter } from "./sign-in-rate-limit";
343345
import { drizzleAdapter } from "better-auth/adapters/drizzle";
344346
import { type Context, Hono, type Next } from "hono";
@@ -1270,8 +1272,14 @@ export async function createHub(config: HubConfig) {
12701272
// agents never produce text. `config.baseUrl` (not `localhost`) is
12711273
// what makes the URL usable from a sidecar on another machine.
12721274
app.route("/api/chat/noop-inference", createNoopInferenceRoutes());
1275+
const selfApi = createHubAPI(config.baseUrl);
1276+
const sessionFor = createBenchSessionMinter({
1277+
auth,
1278+
log: (line) => log.warn`${line}`,
1279+
});
12731280
const chatTenancy = createDrizzleWorkbenchTenancyStore(db, {
12741281
conditionRegistry: chatConditionRegistry,
1282+
api: selfApi,
12751283
});
12761284
// Mounted outside the tenant prefix, like `/api/onboarding`: the bench
12771285
// switcher asks this across every tenant a signed-in user belongs to,
@@ -1601,6 +1609,7 @@ export async function createHub(config: HubConfig) {
16011609
turnQueue,
16021610
authenticator: createWorkflowRunAuthenticator({ db }),
16031611
tenancy: chatTenancy,
1612+
sessionFor,
16041613
}),
16051614
);
16061615
// Slack tag ingress (CL-5288 Phase 1): mounted OUTSIDE the tenant
@@ -1618,6 +1627,7 @@ export async function createHub(config: HubConfig) {
16181627
chatPlatform,
16191628
roomMessages,
16201629
chatTenancy,
1630+
sessionFor,
16211631
workbenchSubscribers,
16221632
turnQueue,
16231633
});
@@ -2238,43 +2248,10 @@ export async function createHub(config: HubConfig) {
22382248
});
22392249
return row?.id;
22402250
},
2241-
hasRepoGrant: async (tenantId, repo) => {
2242-
const existing = await db.query.grant.findFirst({
2243-
where: and(
2244-
eq(grantTable.tenantId, tenantId),
2245-
eq(grantTable.resource, `repo:${repo.name}`),
2246-
eq(grantTable.action, "read"),
2247-
),
2248-
columns: { id: true },
2249-
});
2250-
return existing !== undefined;
2251-
},
2252-
mintRepoGrant: async (tenantId, repo) => {
2253-
const memberRole = await db.query.role.findFirst({
2254-
where: and(
2255-
eq(roleTable.tenantId, tenantId),
2256-
eq(roleTable.name, "member"),
2257-
),
2258-
columns: { id: true },
2259-
});
2260-
if (memberRole === undefined) {
2261-
throw new Error(
2262-
`tenant ${tenantId} has no system "member" role to scope a repo grant to`,
2263-
);
2264-
}
2265-
const now = new Date();
2266-
await db.insert(grantTable).values({
2267-
id: generateId("grant"),
2268-
tenantId,
2269-
roleId: memberRole.id,
2270-
resource: `repo:${repo.name}`,
2271-
action: "read",
2272-
effect: "allow",
2273-
origin: "system",
2274-
createdAt: now,
2275-
updatedAt: now,
2276-
});
2277-
},
2251+
hasRepoGrant: (tenantId, repo, cookies) =>
2252+
hasRepoGrantViaHttp(selfApi, tenantId, repo, cookies),
2253+
mintRepoGrant: (tenantId, repo, cookies) =>
2254+
mintRepoGrantViaHttp(selfApi, tenantId, repo, cookies),
22782255
createWebhookTrigger: async (
22792256
tenantId,
22802257
principalId,
@@ -3233,7 +3210,6 @@ export async function createHub(config: HubConfig) {
32333210
// patching any vendor route.
32343211
await applyAccessPolicyMigrations(config.databaseUrl);
32353212
const accessPolicyStore = createDrizzleAccessPolicyStore(db);
3236-
const selfApi = createHubAPI(config.baseUrl);
32373213
app.route(
32383214
`${TENANT_PREFIX}/access-policy`,
32393215
createAccessPolicyRoutes({
@@ -3261,10 +3237,7 @@ export async function createHub(config: HubConfig) {
32613237
hubUrl: config.baseUrl,
32623238
store: pendingSeedStore,
32633239
pushWorkflow: createGitWorkflowPusher(),
3264-
sessionFor: createBenchSessionMinter({
3265-
auth,
3266-
log: (line) => log.warn`${line}`,
3267-
}),
3240+
sessionFor,
32683241
log: (line) => log.info`${line}`,
32693242
logError: (line) => log.error`${line}`,
32703243
});
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Repo grants for GitHub start-reviewing go through native tenant HTTP,
2+
// never a SQL insert into Interchange grant/role tables.
3+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4+
import { configureSync, resetSync } from "@intx/log";
5+
import type { ApiCall } from "@workbench/hub-client";
6+
7+
import {
8+
hasRepoGrantViaHttp,
9+
mintRepoGrantViaHttp,
10+
} from "./native-repo-grants";
11+
12+
let records: { properties: Record<string, unknown> }[];
13+
14+
function installCapturingSink(): void {
15+
records = [];
16+
configureSync({
17+
reset: true,
18+
sinks: {
19+
capture: (record) => {
20+
records.push(record as { properties: Record<string, unknown> });
21+
},
22+
},
23+
loggers: [
24+
{ category: ["errors"], sinks: ["capture"], lowestLevel: "debug" },
25+
{ category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" },
26+
],
27+
});
28+
}
29+
30+
beforeEach(() => installCapturingSink());
31+
afterEach(() => resetSync());
32+
33+
const REPO = { id: "1", name: "acme/widgets" };
34+
const TENANT_ID = "tnt_bench";
35+
const MEMBER_ROLE_ID = "rol_member";
36+
const COOKIES = ["session=alice"];
37+
38+
function rolesPage() {
39+
return {
40+
data: [
41+
{
42+
id: MEMBER_ROLE_ID,
43+
tenantId: TENANT_ID,
44+
name: "member",
45+
description: "System member role",
46+
isSystem: true,
47+
createdAt: "2026-01-01T00:00:00.000Z",
48+
updatedAt: "2026-01-01T00:00:00.000Z",
49+
},
50+
],
51+
nextCursor: null,
52+
};
53+
}
54+
55+
describe("mintRepoGrantViaHttp", () => {
56+
test("POSTs /api/tenants/:id/grants for repo:<name> read and never needs SQL", async () => {
57+
const posts: {
58+
path: string;
59+
body: unknown;
60+
cookies: string[] | undefined;
61+
}[] = [];
62+
const api: ApiCall = async (method, path, body, cookies) => {
63+
if (
64+
method === "GET" &&
65+
path.startsWith(`/api/tenants/${TENANT_ID}/roles`)
66+
) {
67+
return { status: 200, data: rolesPage(), cookies: cookies ?? [] };
68+
}
69+
if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) {
70+
posts.push({ path, body, cookies });
71+
return { status: 201, data: { id: "grt_1" }, cookies: cookies ?? [] };
72+
}
73+
throw new Error(`unexpected ${method} ${path}`);
74+
};
75+
76+
await mintRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES);
77+
78+
expect(posts).toHaveLength(1);
79+
expect(posts[0]?.cookies).toEqual(COOKIES);
80+
expect(posts[0]?.body).toEqual({
81+
roleId: MEMBER_ROLE_ID,
82+
resource: "repo:acme/widgets",
83+
action: "read",
84+
effect: "allow",
85+
origin: "creator",
86+
});
87+
expect(records).toHaveLength(0);
88+
});
89+
90+
test("reports and rethrows when POST /grants is rejected", async () => {
91+
const api: ApiCall = async (method, path, _body, cookies) => {
92+
if (
93+
method === "GET" &&
94+
path.startsWith(`/api/tenants/${TENANT_ID}/roles`)
95+
) {
96+
return { status: 200, data: rolesPage(), cookies: cookies ?? [] };
97+
}
98+
if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) {
99+
return {
100+
status: 403,
101+
data: { error: { code: "forbidden", message: "nope" } },
102+
cookies: cookies ?? [],
103+
};
104+
}
105+
throw new Error(`unexpected ${method} ${path}`);
106+
};
107+
108+
await expect(
109+
mintRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES),
110+
).rejects.toThrow(
111+
"POST /api/tenants/tnt_bench/grants failed with status 403",
112+
);
113+
114+
expect(records).toHaveLength(1);
115+
expect(records[0]?.properties).toEqual(
116+
expect.objectContaining({
117+
operation: "mintRepoGrant",
118+
tenantId: TENANT_ID,
119+
extra: { repo: "acme/widgets" },
120+
}),
121+
);
122+
});
123+
});
124+
125+
describe("hasRepoGrantViaHttp", () => {
126+
test("is true when GET /grants already lists repo:<name> read allow", async () => {
127+
const api: ApiCall = async (method, path, _body, cookies) => {
128+
if (
129+
method === "GET" &&
130+
path.startsWith(`/api/tenants/${TENANT_ID}/grants?resource=`)
131+
) {
132+
return {
133+
status: 200,
134+
data: {
135+
data: [
136+
{
137+
id: "grt_1",
138+
tenantId: TENANT_ID,
139+
roleId: MEMBER_ROLE_ID,
140+
roleName: "member",
141+
principalId: null,
142+
principalName: null,
143+
resource: "repo:acme/widgets",
144+
action: "read",
145+
effect: "allow",
146+
conditions: null,
147+
origin: "creator",
148+
expiresAt: null,
149+
createdAt: "2026-01-01T00:00:00.000Z",
150+
updatedAt: "2026-01-01T00:00:00.000Z",
151+
},
152+
],
153+
nextCursor: null,
154+
},
155+
cookies: cookies ?? [],
156+
};
157+
}
158+
throw new Error(`unexpected ${method} ${path}`);
159+
};
160+
161+
expect(await hasRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES)).toBe(true);
162+
});
163+
164+
test("is false when GET /grants lists no matching row", async () => {
165+
const api: ApiCall = async (method, path, _body, cookies) => {
166+
if (
167+
method === "GET" &&
168+
path.startsWith(`/api/tenants/${TENANT_ID}/grants`)
169+
) {
170+
return {
171+
status: 200,
172+
data: { data: [], nextCursor: null },
173+
cookies: cookies ?? [],
174+
};
175+
}
176+
throw new Error(`unexpected ${method} ${path}`);
177+
};
178+
179+
expect(await hasRepoGrantViaHttp(api, TENANT_ID, REPO, COOKIES)).toBe(
180+
false,
181+
);
182+
});
183+
});

0 commit comments

Comments
 (0)