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
1 change: 1 addition & 0 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"dependencies": {
"@chat-adapter/state-memory": "^4.35.0",
"@corbits/access-tools": "workspace:*",
"@corbits/agent-directory": "workspace:*",
"@corbits/agent-lifecycle": "workspace:*",
"@corbits/approvals": "workspace:*",
Expand Down
19 changes: 19 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ import {
createDrizzleSidecarPlacementStore,
createSidecarPlacementRoutes,
} from "@corbits/sidecar-placement";
import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes";
import { generateId } from "@intx/hub-common";

import { ensureDefaultTenant } from "./default-tenant";
Expand Down Expand Up @@ -2865,6 +2866,24 @@ export async function createHub(config: HubConfig) {
getPolicy: (tenantId) => benchModelPolicy.store.getPolicy(tenantId),
}),
);
// Myra's own principal/grant surface (`@corbits/access-tools`'
// `list_principals`/`list_grants`/`grant_access`/`revoke_access`): the
// workflow-run-authenticated counterpart to the tenant-session
// `/api/tenants/:tenantId/principals` and `/grants` routes
// `@intx/hub-api` mounts above. Reuses the SAME `chatGrantStore`/
// `chatConditionRegistry` every other extension's own requireGrant
// check runs against, so a seeded principal's real grants (see
// `@corbits/seeding`'s `SEED_GRANTS`) gate this surface exactly like
// every other write route.
app.route(
"/api/workflow-access",
createWorkflowAccessRoutes({
db,
authenticator: createWorkflowRunAuthenticator({ db }),
grantStore: chatGrantStore,
conditionRegistry: chatConditionRegistry,
}),
);
// Notify-to-reconnect for an OAuth-connected credential whose token
// expired (Hugging Face today — see docs/onboarding-huggingface-connect.md):
// a light periodic sweep over `@corbits/notify`'s pure
Expand Down
5 changes: 5 additions & 0 deletions apps/sidecar/src/workflow-substrate-factory/step-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ export function createSidecarStepBuildEnv(
hubAgentDirectoryUrl: string;
hubChatUrl: string;
hubWorkflowAuthoringUrl: string;
hubAccessUrl: string;
sidecarToken: string;
definitionId: string;
} = {
Expand Down Expand Up @@ -426,6 +427,10 @@ export function createSidecarStepBuildEnv(
// (`requires: ["hubWorkflowAuthoringUrl", "sidecarToken", "address"]`)
// for `@corbits/workflows`'s `./authoring`'s run-authenticated routes.
hubWorkflowAuthoringUrl: deps.hubArtifactsUrl,
// And once more under the key `@corbits/access-tools` declares
// (`requires: ["hubAccessUrl", "sidecarToken", "address"]`) for its
// own workflow-run-authenticated principal/grant surface.
hubAccessUrl: deps.hubArtifactsUrl,
sidecarToken: deps.sidecarToken,
definitionId: deps.definitionId,
};
Expand Down
242 changes: 140 additions & 102 deletions bun.lock

Large diffs are not rendered by default.

176 changes: 176 additions & 0 deletions packages/access-tools/LICENSE

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions packages/access-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# @corbits/access-tools

Myra's principal and grant tools — a thin wrapper over the tenant's real
principal and grant rows, gated by Interchange's own grant-store
authorization (`@intx/authz`), never a reimplementation of it. Lets an
agent see who exists in its tenant and, once a human approves, grant or
revoke scoped access for the specialist agents it stands up.

`list_principals` lists every principal (user, agent, or workflow run)
in the calling agent's tenant, with its id, kind, and status, so a grant
can target the right principal id.

`list_grants` lists grants in the tenant, optionally filtered by
`principalId` or `resource`, so an agent can see what a principal
already has before granting more, or find the grant id `revoke_access`
needs.

`grant_access` grants a principal one or more actions on a resource
string (e.g. `read` on `workflow-run:*`). Declared `approval: "ask"` —
a human must approve the specific principal/resource/actions triple
before anything is written.

`revoke_access` revokes a previously created grant by its id. Also
declared `approval: "ask"`.

See `./src/routes.ts` for the workflow-run-authenticated hub-side
routes these tools call, mounted at `/api/workflow-access`.
32 changes: 32 additions & 0 deletions packages/access-tools/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@corbits/access-tools",
"private": true,
"description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, plus the workflow-run-authenticated routes they call — a thin wrapper over the tenant's real principal/grant rows and Interchange's own grant-store authorization, never a reimplementation of authz",
"version": "0.0.1",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
".": "./src/index.ts",
"./routes": "./src/routes.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@corbits/error-sink": "workspace:*",
"@intx/agent": "workspace:*",
"@intx/authz": "0.3.0",
"@intx/db": "workspace:*",
"@intx/hub-api": "workspace:*",
"@intx/hub-common": "0.3.0",
"@intx/types": "workspace:*",
"arktype": "catalog:",
"drizzle-orm": "catalog:",
"hono": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
158 changes: 158 additions & 0 deletions packages/access-tools/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { expect, test } from "bun:test";

import {
AccessForbiddenError,
AccessNotFoundError,
grantAccess,
listGrants,
listPrincipals,
revokeAccess,
type AccessToolClientConfig,
} from "./client";

function testConfig(fetchImpl: typeof fetch): AccessToolClientConfig {
return {
hubAccessUrl: "https://hub.example.com/api/workflow-access",
sidecarToken: "sc-token",
address: "run_1@workflow",
fetchImpl,
};
}

test("listPrincipals sends sidecar auth and parses the response", async () => {
let seenUrl: string | undefined;
let seenHeaders: Record<string, string> | undefined;
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
seenUrl = String(url);
seenHeaders = init?.headers as Record<string, string>;
return new Response(
JSON.stringify({
principals: [
{ id: "prin_1", kind: "workflow", refId: "run_1", status: "active" },
],
}),
);
}) as unknown as typeof fetch;

const result = await listPrincipals(testConfig(fetchImpl));

expect(seenUrl).toBe(
"https://hub.example.com/api/workflow-access/principals",
);
expect(seenHeaders?.["authorization"]).toBe("Bearer sc-token");
expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow");
expect(result).toEqual([
{ id: "prin_1", kind: "workflow", refId: "run_1", status: "active" },
]);
});

test("listGrants filters by principalId and resource", async () => {
let seenUrl: string | undefined;
const fetchImpl = (async (url: string | URL) => {
seenUrl = String(url);
return new Response(JSON.stringify({ grants: [] }));
}) as unknown as typeof fetch;

await listGrants(testConfig(fetchImpl), {
principalId: "prin_1",
resource: "workflow-run:*",
});

const parsed = new URL(seenUrl ?? "");
expect(parsed.searchParams.get("principalId")).toBe("prin_1");
expect(parsed.searchParams.get("resource")).toBe("workflow-run:*");
});

test("grantAccess round-trips the created grants", async () => {
let seenBody: unknown;
const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
seenBody = JSON.parse(String(init?.body));
return new Response(
JSON.stringify({
grants: [
{
id: "grant_1",
principalId: "prin_2",
resource: "workflow-run:*",
action: "read",
effect: "allow",
},
],
}),
{ status: 201 },
);
}) as unknown as typeof fetch;

const result = await grantAccess(testConfig(fetchImpl), {
principalId: "prin_2",
resource: "workflow-run:*",
actions: ["read"],
});

expect(seenBody).toEqual({
principalId: "prin_2",
resource: "workflow-run:*",
actions: ["read"],
});
expect(result).toEqual([
{
id: "grant_1",
principalId: "prin_2",
resource: "workflow-run:*",
action: "read",
effect: "allow",
},
]);
});

test("a 403 from grantAccess surfaces as AccessForbiddenError with the hub's userMessage", async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
error: {
code: "forbidden",
userMessage: "You do not have permission to perform this action",
},
}),
{ status: 403 },
)) as unknown as typeof fetch;

await expect(
grantAccess(testConfig(fetchImpl), {
principalId: "prin_2",
resource: "workflow-run:*",
actions: ["read"],
}),
).rejects.toThrow(AccessForbiddenError);
});

test("a 404 from revokeAccess surfaces as AccessNotFoundError", async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
error: { code: "not_found", userMessage: "No grant here" },
}),
{ status: 404 },
)) as unknown as typeof fetch;

await expect(
revokeAccess(testConfig(fetchImpl), "grant_missing"),
).rejects.toThrow(AccessNotFoundError);
});

test("revokeAccess sends a DELETE to the grant's own path", async () => {
let seenUrl: string | undefined;
let seenMethod: string | undefined;
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
seenUrl = String(url);
seenMethod = init?.method;
return new Response(null, { status: 204 });
}) as unknown as typeof fetch;

await revokeAccess(testConfig(fetchImpl), "grant_1");

expect(seenUrl).toBe(
"https://hub.example.com/api/workflow-access/grants/grant_1",
);
expect(seenMethod).toBe("DELETE");
});
Loading
Loading