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
7 changes: 5 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,10 @@ Body: CreateTenant
### GET /api/tenants/:tenantId
Get tenant details

Requires read on the tenant (`tenant:<id>` / `read`).

200: TenantResponse -- Tenant details
403: ErrorResponse -- Not an active member of this tenant
403: ErrorResponse -- Not an active member of this tenant, or insufficient grants
404: ErrorResponse -- Tenant not found
409: ErrorResponse -- Stored tenant config is invalid

Expand Down Expand Up @@ -452,11 +454,12 @@ Revoke a grant
### POST /api/tenants/:tenantId/principals/:principalId/evaluate
Evaluate grants for a principal

Evaluates what would happen if a principal attempted an operation. Returns the resolved effect and all matching grants. Useful for debugging authorization. Pass `consumer` to evaluate as a workflow run token's consumer, so grants scoped to that consumer match.
Evaluates what would happen if a principal attempted an operation. Returns the resolved effect and all matching grants. Useful for debugging authorization. Pass `consumer` to evaluate as a workflow run token's consumer, so grants scoped to that consumer match. Requires read on grants (`grant:*` / `read`).

Body: EvaluateRequest

200: EvaluateResult -- Evaluation result
403: ErrorResponse -- Insufficient grants
404: ErrorResponse -- Principal not found

## Runs
Expand Down
4 changes: 3 additions & 1 deletion docs/ROUTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ Response types are documented in the OpenAPI spec via the same ArkType types but

There are no dedicated "binding" endpoints for connecting resources to principals. No `POST /wallets/:walletId/agents/:agentId` to give an agent wallet access. No `POST /credentials/:credentialId/agents/:agentId` to bind a credential.

Instead, all authorization flows through capability grants. The grant endpoints (`/api/tenants/:tenantId/grants`) manage tenant-level policies and role-based grants. These are the materialized grants that live on principals. The evaluate endpoint (`/api/tenants/:tenantId/principals/:principalId/evaluate`) lets operators debug authorization by asking "what would happen if this principal tried to do X?" In the same discovery vein, `GET /api/tenants/:tenantId/credentials/resolve/:name` resolves a named credential through the tenant hierarchy and returns its metadata (no secret) — which credential an agent would get.
Instead, all authorization flows through capability grants. The grant endpoints (`/api/tenants/:tenantId/grants`) manage tenant-level policies and role-based grants. These are the materialized grants that live on principals. The evaluate endpoint (`/api/tenants/:tenantId/principals/:principalId/evaluate`) lets operators debug authorization by asking "what would happen if this principal tried to do X?" It requires read on grants (`grant:*` / `read`), because its response lists the principal's matching grants. In the same discovery vein, `GET /api/tenants/:tenantId/credentials/resolve/:name` resolves a named credential through the tenant hierarchy and returns its metadata (no secret) — which credential an agent would get.

Every route under `/api/tenants/:tenantId` checks a grant, so grants are the only control on the tenant API. A route mounts `requireGrant` ahead of its handler, on the route itself or on a `/*` prefix above it. A route whose resource comes from data that the handler reads (a request body, a database row, a git token) checks its grant in the handler instead, and it has an entry with a reason in `GRANT_EXEMPTIONS` in `packages/hub-api/src/route-registration.test.ts`. That unit test fails when a tenant route has neither. It proves that a check is present, not that its resource and action are right.

Agent definitions declare grant requirements with source annotations (tenant, creator, invoker). These requirements are resolved at launch time by the control plane, which materializes grants on the agent's new principal. Creator-sourced and invoker-sourced grants are resolved against the respective principal's authority at launch time. Tenant-sourced grants are resolved from tenant role and system policies. The grant API endpoints manage the tenant-level policy layer; the launch flow handles per-agent materialization.

Expand Down
3 changes: 2 additions & 1 deletion packages/hub-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ export function mountHubRoutes(
);
app.route(
"/api/tenants/:tenantId/principals/:principalId/evaluate",
createEvaluateRoutes({ db, grantStore, conditionRegistry }),
createEvaluateRoutes({ db, requireGrant, grantStore, conditionRegistry }),
);
// The run management surface -- list, observe, stop, mail a single run.
// Mounted as `/workflows/runs` (tenant-wide runs) before the `/workflows`
Expand Down Expand Up @@ -463,6 +463,7 @@ export function mountHubRoutes(
},
}
: {}),
requireGrant,
grantStore,
grantStoreFor,
conditionRegistry,
Expand Down
1 change: 1 addition & 0 deletions packages/hub-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export {
type ResolveApprovalRequest,
type ResolveApprovalOutcome,
type CreateApprovalRoutesDeps,
type ResolveApprovalDeps,
type ReadRunLifecycles,
} from "./routes/approvals";
export { RUN_TOKEN_ROUTES } from "./middleware/run-token-auth";
44 changes: 43 additions & 1 deletion packages/hub-api/src/middleware/grant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ import {
test,
} from "bun:test";
import { Hono } from "hono";
import { every } from "hono/combine";
import { createMiddleware } from "hono/factory";

import { createInMemoryGrantStore, toolConsumerEvaluator } from "@intx/authz";
import { configureSync, getConfig } from "@intx/log";
import type { GrantRule, GrantStore } from "@intx/types/authz";

import type { PrincipalRow, TenantEnv, TenantRow } from "../context";
import { createAuthorizeGrant, createRequireGrant } from "./grant";
import {
createAuthorizeGrant,
createRequireGrant,
idResource,
isGrantCheck,
} from "./grant";

const PRINCIPAL_ID = "prn_caller";
const TENANT_ID = "tnt_a";
Expand Down Expand Up @@ -263,3 +270,38 @@ describe("createRequireGrant with a consumer", () => {
expect((await app.request("/")).status).toBe(200);
});
});

describe("isGrantCheck", () => {
const requireGrant = createRequireGrant({
grantStore: createInMemoryGrantStore([]),
conditionRegistry: {},
});

test("recognizes a check for a fixed resource", () => {
expect(isGrantCheck(requireGrant("widget:*", "read"))).toBe(true);
});

test("recognizes a check for a resource read from the URL", () => {
expect(isGrantCheck(requireGrant(idResource("widget", "id"), "read"))).toBe(
true,
);
});

test("recognizes a check from any requireGrant factory", () => {
const other = createRequireGrant({
grantStore: createInMemoryGrantStore([]),
conditionRegistry: {},
});
expect(isGrantCheck(other("widget:*", "read"))).toBe(true);
});

test("does not recognize other middleware", () => {
expect(isGrantCheck(createMiddleware(async (_c, next) => next()))).toBe(
false,
);
});

test("does not recognize a wrapper around a grant check", () => {
expect(isGrantCheck(every(requireGrant("widget:*", "read")))).toBe(false);
});
});
16 changes: 15 additions & 1 deletion packages/hub-api/src/middleware/grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export function createAuthorizeGrant({
};
}

const grantChecks = new WeakSet<object>();

/**
* Builds a `requireGrant` middleware factory bound to the application's
* grant store and condition registry. Usage:
Expand All @@ -100,7 +102,7 @@ export function createAuthorizeGrant({
export function createRequireGrant(deps: CreateRequireGrantDeps): RequireGrant {
const authorizeGrant = createAuthorizeGrant(deps);
return function requireGrant(resource, action) {
return createMiddleware<TenantEnv>(async (c, next) => {
const middleware = createMiddleware<TenantEnv>(async (c, next) => {
const resolvedResource =
typeof resource === "function"
? resource({ param: (name) => c.req.param(name) })
Expand All @@ -124,9 +126,21 @@ export function createRequireGrant(deps: CreateRequireGrantDeps): RequireGrant {
"You do not have permission to perform this action",
);
});
grantChecks.add(middleware);
return middleware;
};
}

/**
* Whether `handler` is a grant check that `requireGrant` built. Only the
* middleware itself carries the tag: a wrapper around it, such as one that
* `hono/combine` builds, does not, so a check over a route's handler chain
* finds only grant checks mounted directly.
*/
export function isGrantCheck(handler: object): boolean {
return grantChecks.has(handler);
}

/**
* Helper that builds a resource string from a URL parameter.
*
Expand Down
Loading
Loading