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
61 changes: 17 additions & 44 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -1618,6 +1627,7 @@ export async function createHub(config: HubConfig) {
chatPlatform,
roomMessages,
chatTenancy,
sessionFor,
workbenchSubscribers,
turnQueue,
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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}`,
});
Expand Down
183 changes: 183 additions & 0 deletions apps/hub/src/native-repo-grants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// 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, test } from "bun:test";
import { configureSync, resetSync } from "@intx/log";
import type { ApiCall } from "@workbench/hub-client";

import {
hasRepoGrantViaHttp,
mintRepoGrantViaHttp,
} from "./native-repo-grants";

let records: { properties: Record<string, unknown> }[];

function installCapturingSink(): void {
records = [];
configureSync({
reset: true,
sinks: {
capture: (record) => {
records.push(record as { properties: Record<string, unknown> });
},
},
loggers: [
{ category: ["errors"], sinks: ["capture"], lowestLevel: "debug" },
{ category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" },
],
});
}

beforeEach(() => installCapturingSink());
afterEach(() => resetSync());

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:<name> 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(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`)
) {
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(records).toHaveLength(1);
expect(records[0]?.properties).toEqual(
expect.objectContaining({
operation: "mintRepoGrant",
tenantId: TENANT_ID,
extra: { repo: "acme/widgets" },
}),
);
});
});

describe("hasRepoGrantViaHttp", () => {
test("is true when GET /grants already lists repo:<name> 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,
);
});
});
Loading
Loading