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 @@ -11,6 +11,7 @@
}
},
"dependencies": {
"@intx/authz": "workspace:*",
"@intx/crypto": "workspace:*",
"@intx/db": "workspace:*",
"@intx/hub-api": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HUB_CONDITION_REGISTRY } from "@intx/authz";
import {
createDB,
createGrantStore,
Expand All @@ -22,7 +23,6 @@ import {
type SidecarCapabilityRule,
} from "@intx/types";
import {
HUB_CONDITION_REGISTRY,
createApp,
createAuth,
createCorrespondentGrantMinter,
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ Creates a grant targeting either a role or a principal directly. Exactly one of
Body: CreateGrant

201: GrantResponse -- Grant created
400: ErrorResponse -- Validation error
400: ErrorResponse -- Validation error. A body that fails its schema gets the body validator's error envelope. A body whose `conditions` names an unregistered condition, holds a malformed condition body, or, on a workflow run's principal, holds a condition the run cannot evaluate gets an `ErrorResponse` whose message lists each problem as a JSON array.

### GET /api/tenants/:tenantId/grants/:grantId
Get grant details
Expand All @@ -439,6 +439,7 @@ Update effect, conditions, or expiry on an existing grant.
Body: UpdateGrant

200: GrantResponse -- Grant updated
400: ErrorResponse -- Validation error. A body that fails its schema gets the body validator's error envelope. A body whose `conditions` names an unregistered condition, holds a malformed condition body, or, on a workflow run's principal, holds a condition the run cannot evaluate gets an `ErrorResponse` whose message lists each problem as a JSON array.
404: ErrorResponse -- Grant not found

### DELETE /api/tenants/:tenantId/grants/:grantId
Expand Down Expand Up @@ -1363,7 +1364,7 @@ Source: packages/types/src/grants.ts

**effect**: Outcome when this grant is the one resolved for a request: `allow` permits the action, `deny` blocks it, `ask` requires interactive approval before proceeding. When several grants match, the most specific wins, and at equal specificity the strongest effect wins (`deny` over `ask` over `allow`).
**origin**: Records where the grant came from: `system` (built-in), `role` (granted via a role), `creator` (from the workflow definition author), or `invoker` (delegated by the workflow deployment's deployer, its invoker of record). Origin is provenance only; it does not affect evaluation precedence.
**conditions**: Optional map of named conditions that must all pass for the grant to apply, evaluated against a condition registry at authorization time. A check with no request to evaluate conditions against, such as delegation at launch, treats a conditioned `deny` or `ask` as applying and a conditioned `allow` as not applying.
**conditions**: Optional map of named conditions that must all pass for the grant to apply, evaluated against a condition registry at authorization time. Grant create and update reject, with a 400, a condition that any registry evaluating the grant cannot evaluate: an unregistered name, a malformed body, or, on a workflow run's principal, a condition the run cannot evaluate. A check with no request to evaluate conditions against, such as delegation at launch, treats a conditioned `deny` or `ask` as applying and a conditioned `allow` as not applying.

### CreateModel
`{ canonicalName: string, description?: string | null, displayName?: string | null }`
Expand Down Expand Up @@ -1482,7 +1483,7 @@ Source: packages/types/src/grants.ts

**effect**: Outcome when this grant is the one resolved for a request: `allow` permits the action, `deny` blocks it, `ask` requires interactive approval before proceeding. When several grants match, the most specific wins, and at equal specificity the strongest effect wins (`deny` over `ask` over `allow`).
**origin**: Records where the grant came from: `system` (built-in), `role` (granted via a role), `creator` (from the workflow definition author), or `invoker` (delegated by the workflow deployment's deployer, its invoker of record). Origin is provenance only; it does not affect evaluation precedence.
**conditions**: Optional map of named conditions that must all pass for the grant to apply, evaluated against a condition registry at authorization time. A check with no request to evaluate conditions against, such as delegation at launch, treats a conditioned `deny` or `ask` as applying and a conditioned `allow` as not applying.
**conditions**: Optional map of named conditions that must all pass for the grant to apply, evaluated against a condition registry at authorization time. Grant create and update reject, with a 400, a condition that any registry evaluating the grant cannot evaluate: an unregistered name, a malformed body, or, on a workflow run's principal, a condition the run cannot evaluate. A check with no request to evaluate conditions against, such as delegation at launch, treats a conditioned `deny` or `ask` as applying and a conditioned `allow` as not applying.

### HistoryEntry
`{ author: string, message: string, ref: string, timestamp: string, filesChanged?: number }`
Expand Down Expand Up @@ -1625,7 +1626,7 @@ Source: packages/types/src/credentials.ts
`{ conditions?: { [string]: unknown } | null, effect?: "allow" | "ask" | "deny", expiresAt?: string | null }`
Source: packages/types/src/grants.ts

**conditions**: Optional map of named conditions that must all pass for the grant to apply, evaluated against a condition registry at authorization time. A check with no request to evaluate conditions against, such as delegation at launch, treats a conditioned `deny` or `ask` as applying and a conditioned `allow` as not applying.
**conditions**: Optional map of named conditions that must all pass for the grant to apply, evaluated against a condition registry at authorization time. Grant create and update reject, with a 400, a condition that any registry evaluating the grant cannot evaluate: an unregistered name, a malformed body, or, on a workflow run's principal, a condition the run cannot evaluate. A check with no request to evaluate conditions against, such as delegation at launch, treats a conditioned `deny` or `ask` as applying and a conditioned `allow` as not applying.
**effect**: Outcome when this grant is the one resolved for a request: `allow` permits the action, `deny` blocks it, `ask` requires interactive approval before proceeding. When several grants match, the most specific wins, and at equal specificity the strongest effect wins (`deny` over `ask` over `allow`).

### UpdateModel
Expand Down
23 changes: 19 additions & 4 deletions docs/AUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Each requirement specifies:
action: string -- "invoke", "spend", "read", etc.
source: "creator" | "invoker"
effect?: "allow" | "ask" | "deny" -- default: "allow"
conditions?: object -- optional constraints
conditions?: object -- optional constraints; only conditions a run can evaluate (see Conditions)
}
```

Expand Down Expand Up @@ -331,16 +331,31 @@ A standing decision changes the effect of an existing grant whose resource is th

### Conditions

The `conditions` JSONB field constrains when a grant applies. A condition key is evaluated only if an evaluator is registered for it; today the sole registered evaluator is `time_window`:
The `conditions` JSONB field constrains when a grant applies. Each key names a condition evaluator in the hub's condition registry. Two evaluators are registered:

- `{ "time_window": { "after": "09:00", "before": "17:00", "timezone": "America/Los_Angeles" } }` -- time-based access (implemented). `timezone` is required; `after` and `before` are `HH:MM` in 24-hour format.
- `{ "time_window": { "after": "09:00", "before": "17:00", "timezone": "America/Los_Angeles" } }` -- time-based access. `timezone` is required and is an IANA zone name or a UTC offset. `after` and `before` are `HH:MM` in 24-hour format and must differ; `after` later than `before` gives a window that crosses midnight. No other key is allowed.
- `{ "tool": "tool:<package>" }` -- a consumer-scoped grant, which applies only when the named credential consumer makes the request. The value is a non-empty consumer identity.

Spending limits and threshold-based escalation are planned conditions with no evaluator yet:

- `{ "max_spend_per_day": 100, "currency": "USD" }` -- spending limits (not yet available).
- `{ "require_approval_above": 50 }` -- threshold-based escalation (not yet available).

Conditions are evaluated at runtime by the authorization engine: a grant whose registered condition is unmet is skipped, but a grant carrying a condition key with no registered evaluator throws and fails the authorization to surface the misconfiguration. Do not author grants with the planned conditions above until their evaluators ship — a grant referencing an unregistered condition will fail authorization with an error rather than silently passing.
Every grant is evaluated on the hub with this registry. A grant that sits directly on a workflow run's principal is also evaluated where the run is, with narrower registries:

| Where | Evaluates | Registry |
| ------------------- | ----------------------------------------------------------------------------------------------- | ------------------ |
| The run child | a row whose resource pattern can match a resource outside `credential:` | `time_window` only |
| The credential gate | a row whose resource pattern can match `credential:<id>` and whose action pattern matches `use` | `tool` only |

A role grant never reaches a run, because a run loads only the rows on its own principal. So a run-principal row may carry `time_window` on a non-credential resource and `tool` on credential use. A row whose patterns can reach both, such as resource `*` with an action pattern that matches `use`, may carry no condition, because neither key is evaluable in both places.

The hub checks conditions against every registry that will evaluate the row before it stores the row:

- The probe gate (`gateAndFreezeProbeResult`) checks a workflow definition's declared grant requirements, which become rows on the run principal, at install and approve. It refuses the definition with the reason `grant_requirement_conditions_invalid`, before the operator approval checks and before anything is frozen.
- Grant create (`POST`) and update (`PATCH`) check `conditions` for the grant's target, resource, and action. An unregistered key, such as the planned conditions above, a body that does not match its evaluator's shape, or a key that a run cannot evaluate on a run-principal row, gives a 400 that names each problem, and the grant is not stored. A `PATCH` that omits `conditions` does not check or change the stored value.

The authorization engine evaluates conditions at runtime: a grant whose registered condition is unmet is skipped. A stored grant that carries an unregistered key or a malformed body still throws and fails the authorization, to surface the misconfiguration. Such a row comes from a path that does not check conditions: a grant materialized (at launch, or when a change to the delegator's grants re-derives the run) from a declared grant requirement that was frozen before the probe gate checked conditions, a direct database write, or a row stored before the check existed.

Every check names how it treats a conditioned grant. A check on a request evaluates each condition with the hub's condition registry. A check with no request to evaluate a condition against is **pessimistic**: a conditioned `deny` or `ask` applies as if its condition held, and a conditioned `allow` does not apply. Delegation is the pessimistic check:

Expand Down
26 changes: 24 additions & 2 deletions packages/authz/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,39 @@ evaluation is fail-closed.
`ConditionRegistry` evaluates each condition, and `"pessimistic"`
applies a conditioned `deny` or `ask` as if it held and skips a
conditioned `allow`, for a check that has no request.
- `createConditionEvaluator` — build a `ConditionEvaluator` from a
condition name, an arktype schema for the condition body, and an
evaluation callback. The evaluator's `validate` reports what the
schema rejects, and its `evaluate` parses with the same schema and
throws on the same input, so a boundary check and evaluation share
one set of shape rules.
- `timeWindowEvaluator` — a `ConditionEvaluator` for time-of-day
windows. Register it under the `time_window` key in a
`ConditionRegistry` to gate grants on `{ after, before, timezone }`;
cross-midnight windows are supported.
- `toolConsumerEvaluator` — a `ConditionEvaluator` for credential
consumers. Register it under the `tool` key to narrow a grant to one
consumer identity.
- `HUB_CONDITION_REGISTRY` — the hub's standard `ConditionRegistry`,
registering `time_window` and `tool`.
- `RUN_CHILD_CONDITION_REGISTRY` — the `ConditionRegistry` a workflow
run's child process evaluates step grants with: `time_window` only,
a subset of the hub registry.
- `evaluateConditions` — run a grant's `conditions` object against a
`ConditionRegistry`.
- `validateConditions` — check a grant's `conditions` object against a
`ConditionRegistry` without evaluating it, returning one problem per
unregistered key or rejected body. A boundary that stores conditions
calls it to refuse what `evaluateConditions` would later throw on.
- `validateGrantConditions` — check a grant's conditions against every
registry that will evaluate it: the hub registry, and for a row on a
workflow run's principal also the run child registry and the
credential-use registry, where the row's patterns reach them.
- `createInMemoryGrantStore` — a `GrantStore` backed by an in-memory
grant list for tests and single-process deployments.
- `matchPattern`, `patternSpecificity`, `grantSpecificity` — the
colon-segmented wildcard matcher and the specificity scoring used to
pick the winning grant.
- Types: `AuthzResult`, `ConditionContext`, `ConditionEvaluator`,
`ConditionMode`, `ConditionRegistry`, `Effect`, `GrantRule`, `GrantStore`,
`MatchedGrant`.
`ConditionMode`, `ConditionRegistry`, `Effect`, `GrantConditionTarget`,
`GrantRule`, `GrantStore`, `MatchedGrant`.
3 changes: 2 additions & 1 deletion packages/authz/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
}
},
"dependencies": {
"@intx/types": "workspace:*"
"@intx/types": "workspace:*",
"arktype": "catalog:"
},
"files": [
"dist",
Expand Down
56 changes: 56 additions & 0 deletions packages/authz/src/condition-evaluator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, test, expect } from "bun:test";
import { type } from "arktype";

import { createConditionEvaluator } from "./condition-evaluator";
import type { ConditionContext } from "./types";

const ctx: ConditionContext = {
now: new Date("2026-06-15T14:30:00Z"),
resource: "agent:agt_abc",
action: "read",
principalId: "prn_1",
tenantId: "tnt_1",
consumer: "",
};

describe("createConditionEvaluator", () => {
const Limit = type({ max: "number" }).pipe(({ max }) => max * 2);

test("validate returns null for a value the schema accepts", () => {
const evaluator = createConditionEvaluator("limit", Limit, () => true);
expect(evaluator.validate({ max: 1 })).toBeNull();
});

test("validate names the condition and joins problems on one line", () => {
const evaluator = createConditionEvaluator(
"limit",
type({ "+": "reject", max: "number" }),
() => true,
);
expect(evaluator.validate({ cap: 1 })).toBe(
"limit: max must be a number (was missing); cap must be removed",
);
});

test("evaluate passes the parsed value to the callback", async () => {
let received: unknown;
const evaluator = createConditionEvaluator("limit", Limit, (value) => {
received = value;
return true;
});
expect(await evaluator.evaluate({ max: 3 }, ctx)).toBe(true);
expect(received).toBe(6);
});

test("evaluate throws the validate message without calling back", () => {
let called = false;
const evaluator = createConditionEvaluator("limit", Limit, () => {
called = true;
return true;
});
expect(() => evaluator.evaluate({ max: "x" }, ctx)).toThrow(
"limit: max must be a number (was a string)",
);
expect(called).toBe(false);
});
});
40 changes: 40 additions & 0 deletions packages/authz/src/condition-evaluator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { type, type Type } from "arktype";

import type { ConditionContext, ConditionEvaluator } from "./types";

/**
* Build a condition evaluator whose shape check and evaluation share one
* schema. The returned `validate` reports what the schema rejects. The
* returned `evaluate` parses with the same schema, throws on the same input,
* and passes the parsed body to the `evaluate` callback. A stored body that
* bypassed `validate` therefore still fails loud at evaluation time.
*
* Every message starts with `name`, which is the condition's registry key.
*/
export function createConditionEvaluator<S extends Type>(
name: string,
schema: S,
evaluate: (
value: S["infer"],
ctx: ConditionContext,
) => boolean | Promise<boolean>,
): ConditionEvaluator {
// One line per body, so a caller can join several problems into one
// message.
const describe = (errors: type.errors) =>
`${name}: ${errors.map((e) => e.message).join("; ")}`;

return {
validate(value) {
const parsed = schema(value);
return parsed instanceof type.errors ? describe(parsed) : null;
},
evaluate(value, ctx) {
const parsed = schema(value);
if (parsed instanceof type.errors) {
throw new Error(describe(parsed));
}
return evaluate(parsed, ctx);
},
};
}
34 changes: 34 additions & 0 deletions packages/authz/src/condition-registry-invariants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, test, expect } from "bun:test";

import { HUB_CONDITION_REGISTRY } from "./hub-condition-registry";
import { RUN_CHILD_CONDITION_REGISTRY } from "./run-child-condition-registry";
import { CREDENTIAL_USE_CONDITIONS } from "./tool-consumer";
import type { ConditionRegistry } from "./types";

const REGISTRIES: [string, Readonly<ConditionRegistry>][] = [
["HUB_CONDITION_REGISTRY", HUB_CONDITION_REGISTRY],
["CREDENTIAL_USE_CONDITIONS", CREDENTIAL_USE_CONDITIONS],
["RUN_CHILD_CONDITION_REGISTRY", RUN_CHILD_CONDITION_REGISTRY],
];

describe("condition registries", () => {
// validateConditions passes an evaluator's message through without adding
// the key, so each evaluator must name itself by its registry key.
for (const [name, registry] of REGISTRIES) {
test(`${name}: each evaluator names itself by its registry key`, () => {
for (const [key, evaluator] of Object.entries(registry)) {
expect(evaluator.validate(undefined)).toStartWith(`${key}: `);
}
});
}

// A run's grant rows are evaluated both on the hub and in the run child, so
// a condition the child accepts must mean the same thing on the hub.
test("the run child registry is a subset of the hub registry", () => {
for (const [key, evaluator] of Object.entries(
RUN_CHILD_CONDITION_REGISTRY,
)) {
expect(HUB_CONDITION_REGISTRY[key]).toBe(evaluator);
}
});
});
Loading
Loading