@ultimat3/policy 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,101 @@
1
+ # @ultimat3/policy
2
+
3
+ The one authz rule, evaluated in every surface. Tier 2.
4
+
5
+ ## Boundary
6
+
7
+ - May import: `@ultimat3/core`. That is all it needs.
8
+ - Never import `@ultimat3/http`/`@ultimat3/entity` (same tier) or any surface package.
9
+ Surface denial shapes are declared structurally in `surfaces.ts`.
10
+
11
+ ## The one predicate signature
12
+
13
+ ```ts
14
+ interface PolicyArgs<I = unknown, R = unknown> {
15
+ readonly input: I;
16
+ readonly actor: Actor | null;
17
+ readonly row: R | null; // required; `null` = this rule decides on input alone
18
+ readonly ctx?: Ctx;
19
+ }
20
+ ```
21
+
22
+ Every predicate on every surface sees these four fields. `row` is `R | null` rather than
23
+ `row?: R` on purpose — an optional field lets a surface forget it and lets the two shapes
24
+ drift apart again, which is exactly how the realtime row gate ended up nesting the row
25
+ inside `input`.
26
+
27
+ `EvaluateArgs<I, R>` is what *callers* pass and its `row` **is** optional; `evaluate()`
28
+ normalises a missing row to `null` before the predicate sees it. That is the only place the
29
+ two differ, and it is why a surface that decides on input alone needs no edit.
30
+
31
+ ## Rules
32
+
33
+ - **Never add a second authz path.** If a surface cannot use `evaluate()`, add an
34
+ adapter to `surfaces.ts` — nothing else.
35
+ - **One predicate shape.** A row-level rule reads `args.row`. Never pass a row through
36
+ `input`, and never add a per-surface args type.
37
+ - A policy is pure and synchronous. No I/O, no `await`. Load the row first, then decide.
38
+ - `reason` must be safe to log: name permissions and clauses, never row data or PII —
39
+ `row` being in scope changes nothing about what a denial is allowed to say. The same
40
+ guarantee is what a `DecisionSink` event inherits: `decisions.ts` carries the label, the
41
+ deciding clause and the actor's identifiers, and **never `row` or `input`**.
42
+ - A missing policy is refused by the **type system**, not by a throw: `ActionDef.policy`
43
+ is a required field in `@ultimat3/action`, so an action without one does not compile.
44
+ `X_POLICY_MISSING` / `policyMissing()` stay published for a declaration site that cannot
45
+ say it in a type (a config-driven route table, a policy resolved by name). Never default
46
+ to allow.
47
+ - `can()` validates its permission at declaration time, not at request time.
48
+ - **`not()` never inverts `X_UNAUTHENTICATED`.** A null actor is not a fact about this
49
+ actor's grants; inverting it makes `not(can('order:internal'))` a public door into the
50
+ internal one. Any denial carrying that code propagates unchanged.
51
+ - **`defineRoles()` merges** and refuses a role two modules define differently
52
+ (`X_ROLE_REDEFINED`, naming both declaration sites). A re-declaration of an *identical*
53
+ role is a no-op, which is what keeps `defineRoles({ ...roleDefinitions(), … })` legal.
54
+ - No `any`. Never throw a bare `Error` — use `errors.ts`.
55
+ - **This package owns `X_FORBIDDEN`** and registers its title with core. `http`, `auth`
56
+ and every surface adapter reuse the code and must not re-register it.
57
+
58
+ ## The one authz rule — and the one honest exception
59
+
60
+ Actions, queries, jobs, MCP tools **and routes** all resolve their rule through `evaluate()`.
61
+ There is no second door. `@ultimat3/auth`'s `requireRole()` / `requireScope()` were one until
62
+ 1.3.0 — they asserted on the ambient actor and never evaluated a policy, and a route gated that
63
+ way was invisible to `x policy list`, to `framework.manifest.json` and to `openapi.json`. They
64
+ are deleted: in the repo's whole history, and in both tracked apps, **nothing ever called them**,
65
+ so the framework shipped a documented invitation to an under-reported route and nobody accepted
66
+ it. `packages/auth/src/guards.test.ts` pins that module's export list, so an authz decision
67
+ reappearing there is a failing test.
68
+
69
+ ## Files
70
+
71
+ | File | Job |
72
+ |---|---|
73
+ | `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording |
74
+ | `evaluate.ts` | the single entry point; builds the trace, emits the one decision event |
75
+ | `decisions.ts` | the `DecisionSink` seam — no-op default, one call site, never PII |
76
+ | `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
77
+ | `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
78
+ | `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
79
+ | `test-kit.ts` | `policyMatrix()` for generated policy tests |
80
+
81
+ ## The hot path
82
+
83
+ A live query evaluates policy **per subscriber on every change event** — one write to a channel
84
+ with 10k watchers is 10k evaluations in one tick. Two consequences, both load-bearing:
85
+
86
+ - `grant-index.ts` memoises the flattened grant set on a `WeakMap<Actor, …>`, invalidated by
87
+ `roleMapGeneration()` — the same shape `@ultimat3/entity`'s `relationMap()` uses against
88
+ `registryGeneration()`. Keyed on the actor **object**, never on its id: `@ultimat3/auth`
89
+ re-reads the user row every request and mints a fresh frozen actor, so a revoked role still
90
+ takes effect on the next request and the entry dies with it. **Never cache an actor, or a
91
+ grant set, across requests** — that is the stale-authz window the framework does not have.
92
+ - The trace is opt-in: on outside production, and in production only once a `DecisionSink` is
93
+ installed. `evaluate(policy, args, { trace: true })` forces it — `policyMatrix()` does,
94
+ because `deciding` *is* the matrix.
95
+
96
+ ## Commands
97
+
98
+ ```
99
+ bun test packages/policy
100
+ bun run --filter @ultimat3/policy typecheck
101
+ ```
package/README.md CHANGED
@@ -12,6 +12,15 @@ export const publishPost = action({
12
12
  });
13
13
  ```
14
14
 
15
+ **No exception, as of 1.3.0.** `@ultimat3/auth`'s `requireRole()` / `requireScope()` used to gate
16
+ *routes* on the ambient actor without evaluating a policy; they are deleted. They were documented
17
+ here as "one honest exception" and had **zero callers** in the framework or in either tracked app —
18
+ a sanctioned second door nobody walked through, whose own documentation admitted that a route
19
+ gated that way is invisible to `x policy list`, to `framework.manifest.json` and to `openapi.json`.
20
+ Gate a route with a `Policy`: `can('admin:access')`, which every introspection surface can read.
21
+ `requireActor()` and `currentActor()` remain — those assert *authentication*, which is what
22
+ `@ultimat3/auth` produces.
23
+
15
24
  ## Shape
16
25
 
17
26
  A policy is a pure `(input, actor, row, ctx) => PolicyDecision`.
@@ -63,7 +72,17 @@ missing row to `null`. A surface that has no row passes `{ input, actor, ctx }`
63
72
  | `allow()` / `deny(reason)` | terminal; `allow()` is how "public" is said out loud |
64
73
  | `and(...)` | first denial wins, its reason is the reason |
65
74
  | `or(...)` | first allowance wins; otherwise the last denial is reported |
66
- | `not(p)` | inverts |
75
+ | `not(p)` | inverts — except `X_UNAUTHENTICATED`, which propagates unchanged |
76
+
77
+ `not()` never turns "there is no actor" into an allow. `can()` denies a null actor with
78
+ `X_UNAUTHENTICATED`, and inverting that would make `not(can('order:internal'))` — the natural
79
+ simplification of `and(can('order:read'), not(can('order:internal')))` — a public door into the
80
+ internal one.
81
+
82
+ `policy.permissions` (or `policyPermissions(policy)`) is the flattened, deduped, sorted list of
83
+ every permission a tree references, `not()` clauses included. It is what a compliance report has
84
+ to read: `label` renders a composite as `and(post:publish, org:administer)`, which is a sentence,
85
+ never a permission.
67
86
 
68
87
  ## Four surfaces, four adapters, one rule
69
88
 
@@ -87,6 +106,15 @@ Adding a fifth surface means adding an adapter here **and nothing else**.
87
106
  `defineRoles({ owner: { grants: ['post:delete'], inherits: ['editor'] } })` expands
88
107
  depth-first to a flat set, cycles included. `post:*` and `*` are supported.
89
108
 
109
+ `defineRoles()` **merges** into the app's one role map. A second call in a new feature folder
110
+ adds roles; it never deletes the first module's. A role two modules define *differently* is
111
+ `X_ROLE_REDEFINED`, naming both declaration sites — and an identical re-declaration is a no-op,
112
+ so `defineRoles({ ...roleDefinitions(), … })` stays legal.
113
+
114
+ The flattened grant set is memoised per actor and invalidated the moment the role map changes.
115
+ It is keyed on the actor object, so it lives exactly as long as the request does: `@ultimat3/auth`
116
+ re-reads the user row every request, and a revoked role takes effect on the next one.
117
+
90
118
  ## Traces
91
119
 
92
120
  `evaluate()` returns a depth-first trace naming the clause that decided. `/_x` renders
@@ -99,10 +127,34 @@ editor deny post:read predicate returned false
99
127
  viewer deny actor lacks post:publish
100
128
  ```
101
129
 
130
+ Building it is opt-in: on outside production, and in production only once a decision sink is
131
+ installed. A live query evaluates policy per subscriber on every change event, so an unread
132
+ `TraceEntry[]` per evaluation is real allocation on the busiest path there is. Force it with
133
+ `evaluate(policy, args, { trace: true })`.
134
+
135
+ ## The decision log
136
+
137
+ ```ts
138
+ setDecisionSink({
139
+ record(event) {
140
+ // { label, allowed, code, reason, actorId, actorKind, orgId, surface, deciding }
141
+ },
142
+ });
143
+ ```
144
+
145
+ No-op until installed, emitted from **one place** — inside `evaluate()`, so a fifth surface
146
+ inherits it — and it records the **allow** as well as the denial, which is the half an access
147
+ review actually asks for. It never carries `row` or `input`: `reason` is safe to log by
148
+ construction, and the sink inherits that guarantee. A sink that throws is logged and swallowed;
149
+ it never turns an allowed request into a 500.
150
+
102
151
  ## Errors
103
152
 
104
- `X_FORBIDDEN` · `X_POLICY_MISSING` (an action with no policy is a **build** error, not
105
- a public endpoint) · `X_PERMISSION_UNKNOWN`
153
+ `X_FORBIDDEN` · `X_POLICY_MISSING` · `X_PERMISSION_UNKNOWN` · `X_ROLE_REDEFINED`
154
+
155
+ A missing policy is a **type** error, not a throw: `ActionDef.policy` is required, so an action
156
+ without one does not compile. `policyMissing()` stays for a declaration site that cannot say it
157
+ in a type — a config-driven route table, a policy resolved by name.
106
158
 
107
159
  ## Boundaries
108
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "The one authz rule, evaluated identically in every surface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,6 +31,6 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.1.0"
34
+ "@ultimat3/core": "2.0.0"
34
35
  }
35
36
  }
@@ -0,0 +1,90 @@
1
+ // The authz decision log's one seam. Shaped exactly like core's `ErrorReporter`: always on, a
2
+ // no-op by default, the wire format supplied by a sink and never here. It exists because the
3
+ // question after an incident is "who did this, and which rule let them?" — and until now an
4
+ // allowed decision left no trace at all, on any of the four surfaces.
5
+ //
6
+ // PII rule, inherited and not negotiable: an event carries the label, the clause and the actor's
7
+ // identifiers. Never `row`, never `input`. `reason` is already safe to log by construction
8
+ // (policy/CLAUDE.md) and this is the guarantee that keeps it that way.
9
+ import { logger, renderThrowable } from '@ultimat3/core';
10
+ import type { Surface } from './surfaces';
11
+
12
+ export interface PolicyDecisionEvent {
13
+ /** The policy's own label — `and(post:publish, org:administer)`. Safe to log. */
14
+ readonly label: string;
15
+ readonly allowed: boolean;
16
+ /** `null` on an allow: there is no code for "yes". */
17
+ readonly code: string | null;
18
+ readonly reason: string | null;
19
+ readonly actorId: string | null;
20
+ readonly actorKind: string | null;
21
+ readonly orgId: string | null;
22
+ /** `null` when the evaluation did not come through a surface adapter. */
23
+ readonly surface: Surface | null;
24
+ /** The clause that decided, by label. `null` when the trace was off. */
25
+ readonly deciding: string | null;
26
+ }
27
+
28
+ /** The driver seam. A SIEM exporter, an append-only table or a log line all arrive as one. */
29
+ export interface DecisionSink {
30
+ record(event: PolicyDecisionEvent): void;
31
+ }
32
+
33
+ export const noopDecisionSink: DecisionSink = Object.freeze({
34
+ record(): void {
35
+ // Intentionally empty: an app that logs no decisions pays one property read per evaluation.
36
+ },
37
+ });
38
+
39
+ let sink: DecisionSink | undefined;
40
+
41
+ export const setDecisionSink = (next: DecisionSink): void => {
42
+ sink = next;
43
+ };
44
+
45
+ /** Test seam, and the only way back to "unconfigured" — which a literal cannot express. */
46
+ export const resetDecisionSink = (): void => {
47
+ sink = undefined;
48
+ };
49
+
50
+ /**
51
+ * Read by `evaluate()` before it builds an event, and by the trace default: a sink is the one
52
+ * reason to keep building a trace in production.
53
+ */
54
+ export const decisionSinkInstalled = (): boolean => sink !== undefined;
55
+
56
+ /**
57
+ * Called from exactly ONE place — inside `evaluate()`, so a fifth surface inherits the log the
58
+ * day it is added rather than the day someone remembers to wire it. Never throws: a sink that is
59
+ * down must not turn an allowed request into a 500.
60
+ */
61
+ export const emitDecision = (event: PolicyDecisionEvent): void => {
62
+ if (sink === undefined) return;
63
+ try {
64
+ sink.record(event);
65
+ } catch (failure) {
66
+ logger.warn('policy decision sink failed', {
67
+ label: event.label,
68
+ error: renderThrowable(failure),
69
+ });
70
+ }
71
+ };
72
+
73
+ export interface MemoryDecisionSink extends DecisionSink {
74
+ readonly events: readonly PolicyDecisionEvent[];
75
+ reset(): void;
76
+ }
77
+
78
+ /** For tests, and for a `x dev` process that shows its own authz decisions without leaving the box. */
79
+ export const memoryDecisionSink = (): MemoryDecisionSink => {
80
+ const events: PolicyDecisionEvent[] = [];
81
+ return {
82
+ events,
83
+ record(event: PolicyDecisionEvent): void {
84
+ events.push(event);
85
+ },
86
+ reset(): void {
87
+ events.length = 0;
88
+ },
89
+ };
90
+ };
package/src/errors.ts CHANGED
@@ -1,12 +1,15 @@
1
- // The policy layer's stable error codes. `X_POLICY_MISSING` is deliberately a build
2
- // error rather than a runtime default: an action with no policy is not "public", it
3
- // is unfinished.
1
+ // The policy layer's stable error codes. `X_POLICY_MISSING` is enforced by the TYPE system,
2
+ // not by a throw: `ActionDef.policy` is a required field (`@ultimat3/action`'s `action.ts`), so
3
+ // an action with no policy never compiles. The code and its factory stay published for a
4
+ // declaration site that cannot express the requirement in a type — a config-driven route table,
5
+ // a policy resolved by name — and `policyMissing()` is how such a site says it.
4
6
  import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
7
 
6
8
  export const POLICY_ERROR_CODES = [
7
9
  'X_FORBIDDEN',
8
10
  'X_POLICY_MISSING',
9
11
  'X_PERMISSION_UNKNOWN',
12
+ 'X_ROLE_REDEFINED',
10
13
  ] as const;
11
14
 
12
15
  export type PolicyErrorCode = (typeof POLICY_ERROR_CODES)[number];
@@ -15,6 +18,7 @@ export const POLICY_ERROR_TITLES: Readonly<Record<PolicyErrorCode, string>> = {
15
18
  X_FORBIDDEN: 'policy denied this actor',
16
19
  X_POLICY_MISSING: 'an action was declared without a policy',
17
20
  X_PERMISSION_UNKNOWN: 'permission string is not in the permission set',
21
+ X_ROLE_REDEFINED: 'two modules define the same role differently',
18
22
  };
19
23
 
20
24
  // This package OWNS X_FORBIDDEN — http, auth, ai, realtime and every other surface adapter throw
@@ -53,6 +57,17 @@ export const policyMissing = (subject: string): PolicyError =>
53
57
  fix: `add policy: can('<resource>:<verb>') to ${subject}, or allow('public') to say so explicitly`,
54
58
  });
55
59
 
60
+ /**
61
+ * Both declaration sites are named because the fix is always "one of these two wins", and which
62
+ * two is the only thing the author does not already know.
63
+ */
64
+ export const roleRedefined = (role: string, first: string, second: string): PolicyError =>
65
+ new PolicyError({
66
+ code: 'X_ROLE_REDEFINED',
67
+ cause: `role "${role}" is defined twice with different grants — first at ${first}, again at ${second}`,
68
+ fix: `x policy list --json # then keep ONE definition of "${role}": rename the second, or fold its grants into the first`,
69
+ });
70
+
56
71
  export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError =>
57
72
  new PolicyError({
58
73
  code: 'X_PERMISSION_UNKNOWN',
package/src/evaluate.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  // One entry point for evaluating a policy, and the only place a decision trace is
2
2
  // built. The trace is what makes an authz denial debuggable: `/_x` renders it, policy
3
3
  // tests assert on it, and an agent reading a 403 can see which clause decided.
4
- import type { Ctx } from '@ultimat3/core';
4
+ // It is also the one place a decision reaches the `DecisionSink` — allowed decisions
5
+ // included, since "who was let in" is the half an audit actually needs.
6
+ import { type Ctx, DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
7
+ import { decisionSinkInstalled, emitDecision } from './decisions';
5
8
  import type { Policy, PolicyDecision, TraceEntry } from './policy';
6
9
  import type { Actor } from './roles';
10
+ import type { Surface } from './surfaces';
7
11
 
8
12
  /**
9
13
  * What a *caller* supplies. `row` is optional here and required in `PolicyArgs`: a surface
@@ -18,21 +22,52 @@ export interface EvaluateArgs<I, R = unknown> {
18
22
  readonly ctx?: Ctx;
19
23
  }
20
24
 
25
+ export interface EvaluateOptions {
26
+ /**
27
+ * Build the trace. Defaults to on outside production, and to on in production only once a
28
+ * `DecisionSink` is installed — a `TraceEntry[]` per evaluation is real allocation on the
29
+ * live-query path, where one write fans out to one evaluation per subscriber.
30
+ */
31
+ readonly trace?: boolean | undefined;
32
+ /** Which adapter asked. Carried to the sink; the decision itself never depends on it. */
33
+ readonly surface?: Surface | undefined;
34
+ }
35
+
21
36
  export interface PolicyEvaluation {
22
37
  readonly allowed: boolean;
23
38
  readonly decision: PolicyDecision;
24
- /** Depth-first, in evaluation order. Empty only for a policy that never ran. */
39
+ /** Depth-first, in evaluation order. Empty for a policy that never ran, or a trace turned off. */
25
40
  readonly trace: readonly TraceEntry[];
26
- /** The clause whose result the caller is looking at. */
41
+ /** The clause whose result the caller is looking at. `null` when the trace is off. */
27
42
  readonly deciding: TraceEntry | null;
28
43
  readonly label: string;
29
44
  }
30
45
 
46
+ let outsideProduction: boolean | undefined;
47
+
48
+ /**
49
+ * Resolved once and cached: `ULTIMATE_ENV` cannot change under a running process, and reading
50
+ * `process.env` per evaluation is exactly the per-subscriber cost this change exists to remove.
51
+ * Non-throwing on purpose — a malformed `ULTIMATE_ENV` is its own error with its own fix, and it
52
+ * must never be raised for the first time by an authz check.
53
+ */
54
+ const traceByDefault = (): boolean => {
55
+ outsideProduction ??= (tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT) !== 'production';
56
+ return outsideProduction || decisionSinkInstalled();
57
+ };
58
+
59
+ /** Test seam: re-reads the environment on the next evaluation. */
60
+ export const resetPolicyTracing = (): void => {
61
+ outsideProduction = undefined;
62
+ };
63
+
31
64
  export const evaluate = <I, R = unknown>(
32
65
  policy: Policy<I, R>,
33
66
  args: EvaluateArgs<I, R>,
67
+ options?: EvaluateOptions,
34
68
  ): PolicyEvaluation => {
35
69
  const trace: TraceEntry[] = [];
70
+ const wanted = options?.trace ?? traceByDefault();
36
71
  const decision = policy.run(
37
72
  {
38
73
  input: args.input,
@@ -43,7 +78,8 @@ export const evaluate = <I, R = unknown>(
43
78
  row: args.row ?? null,
44
79
  ...(args.ctx === undefined ? {} : { ctx: args.ctx }),
45
80
  },
46
- (entry) => trace.push(entry),
81
+ // No recorder at all when the trace is off, so not even the closure is allocated.
82
+ wanted ? (entry) => trace.push(entry) : undefined,
47
83
  );
48
84
  // Entries are recorded post-order (children before their combinator), so the first
49
85
  // entry that agrees with the outcome is the leaf that actually decided — and when a
@@ -55,6 +91,19 @@ export const evaluate = <I, R = unknown>(
55
91
  ) ??
56
92
  trace.find(agrees) ??
57
93
  null;
94
+ if (decisionSinkInstalled()) {
95
+ emitDecision({
96
+ label: policy.label,
97
+ allowed: decision.allowed,
98
+ code: decision.allowed ? null : decision.code,
99
+ reason: decision.allowed ? null : decision.reason,
100
+ actorId: args.actor?.id ?? null,
101
+ actorKind: args.actor?.kind ?? null,
102
+ orgId: args.actor?.orgId ?? null,
103
+ surface: options?.surface ?? null,
104
+ deciding: deciding?.label ?? null,
105
+ });
106
+ }
58
107
  return {
59
108
  allowed: decision.allowed,
60
109
  decision,
@@ -0,0 +1,79 @@
1
+ // One flattened grant set per actor, memoised for as long as the role map it was built from
2
+ // stays put. Why it earns a file: a live query evaluates policy PER SUBSCRIBER on every change
3
+ // event, and every `can()` clause asks this question — so an unmemoised expansion turned one
4
+ // write on a channel with 10k subscribers into 10k role-graph walks in a single tick.
5
+ import { type Permission, resourceOf } from './permissions';
6
+ import { type Actor, expandRoles, type RoleMap, roleDefinitions, roleMapGeneration } from './roles';
7
+
8
+ /**
9
+ * The three shapes `grantMatches()` can take, pre-split so a lookup is a `Set.has` instead of a
10
+ * scan: an exact grant, a `<resource>:*` wildcard, and the bare `*`. Equivalent to
11
+ * `actorPermissions(actor).some((grant) => grantMatches(grant, permission))`, per grant kind.
12
+ */
13
+ interface GrantIndex {
14
+ readonly exact: ReadonlySet<string>;
15
+ /** Resources covered by a `<resource>:*` grant. */
16
+ readonly wildcards: ReadonlySet<string>;
17
+ readonly all: boolean;
18
+ /** Deduped and sorted, built once so `actorPermissions()` never sorts per clause. */
19
+ readonly sorted: readonly string[];
20
+ }
21
+
22
+ interface CacheEntry {
23
+ readonly map: RoleMap;
24
+ readonly generation: number;
25
+ readonly index: GrantIndex;
26
+ }
27
+
28
+ /**
29
+ * Keyed on the actor OBJECT, never on its id: `@ultimat3/auth` mints a fresh, frozen actor per
30
+ * request from a freshly-read user row, so the entry dies with the request and a revoked role
31
+ * still takes effect on the very next one. Caching by id — or across requests — would trade that
32
+ * property for the same allocations.
33
+ */
34
+ const cache = new WeakMap<Actor, CacheEntry>();
35
+
36
+ const buildIndex = (actor: Actor, map: RoleMap): GrantIndex => {
37
+ const exact = new Set<string>(actor.permissions ?? []);
38
+ for (const grant of expandRoles(actor.roles ?? [], map)) exact.add(grant);
39
+ const wildcards = new Set<string>();
40
+ let all = false;
41
+ for (const grant of exact) {
42
+ if (grant === '*') all = true;
43
+ else if (grant.endsWith(':*')) wildcards.add(resourceOf(grant));
44
+ }
45
+ return { exact, wildcards, all, sorted: [...exact].sort() };
46
+ };
47
+
48
+ const indexFor = (actor: Actor, map: RoleMap): GrantIndex => {
49
+ const generation = roleMapGeneration();
50
+ const hit = cache.get(actor);
51
+ // The map is compared by reference as well as by generation, because both `actorHas()` and
52
+ // `actorPermissions()` take an explicit map override that the generation counter never sees.
53
+ if (hit !== undefined && hit.map === map && hit.generation === generation) return hit.index;
54
+ const index = buildIndex(actor, map);
55
+ cache.set(actor, { map, generation, index });
56
+ return index;
57
+ };
58
+
59
+ /**
60
+ * A COPY, per call. `readonly string[]` is compile-time only, so handing back `index.sorted` —
61
+ * which is the per-actor authz cache itself — let any caller `push` a grant into it through a
62
+ * widened reference and hold it for the life of that request. The list is small (an actor's own
63
+ * grants) and this is not on `actorHas`'s path, which reads the `Set` and copies nothing.
64
+ */
65
+ export const actorPermissions = (
66
+ actor: Actor | null,
67
+ map: RoleMap = roleDefinitions(),
68
+ ): readonly string[] => (actor === null ? [] : [...indexFor(actor, map).sorted]);
69
+
70
+ export const actorHas = (
71
+ actor: Actor | null,
72
+ permission: Permission,
73
+ map: RoleMap = roleDefinitions(),
74
+ ): boolean => {
75
+ if (actor === null) return false;
76
+ const index = indexFor(actor, map);
77
+ if (index.all || index.exact.has(permission)) return true;
78
+ return index.wildcards.has(resourceOf(permission));
79
+ };
package/src/index.ts CHANGED
@@ -1,7 +1,14 @@
1
+ // The public surface of @ultimat3/policy. Explicit, never `export *`.
2
+ export type { DecisionSink, MemoryDecisionSink, PolicyDecisionEvent } from './decisions';
3
+ export {
4
+ decisionSinkInstalled,
5
+ memoryDecisionSink,
6
+ noopDecisionSink,
7
+ resetDecisionSink,
8
+ setDecisionSink,
9
+ } from './decisions';
1
10
  export type { DefinePolicyInput } from './define';
2
11
  export { definePolicy } from './define';
3
- // The public surface of @ultimat3/policy. Explicit, never `export *`.
4
-
5
12
  export type { PolicyErrorCode } from './errors';
6
13
  export {
7
14
  forbidden,
@@ -10,9 +17,18 @@ export {
10
17
  PolicyError,
11
18
  permissionUnknown,
12
19
  policyMissing,
20
+ roleRedefined,
13
21
  } from './errors';
14
- export type { EvaluateArgs, PolicyEvaluation } from './evaluate';
15
- export { codeOf, evaluate, explain, reasonOf, renderTrace } from './evaluate';
22
+ export type { EvaluateArgs, EvaluateOptions, PolicyEvaluation } from './evaluate';
23
+ export {
24
+ codeOf,
25
+ evaluate,
26
+ explain,
27
+ reasonOf,
28
+ renderTrace,
29
+ resetPolicyTracing,
30
+ } from './evaluate';
31
+ export { actorHas, actorPermissions } from './grant-index';
16
32
  export type {
17
33
  KnownPermission,
18
34
  Permission,
@@ -37,16 +53,26 @@ export type {
37
53
  Recorder,
38
54
  TraceEntry,
39
55
  } from './policy';
40
- export { ALLOWED, allow, and, can, denied, deny, not, or } from './policy';
56
+ export {
57
+ ALLOWED,
58
+ allow,
59
+ and,
60
+ can,
61
+ denied,
62
+ deny,
63
+ not,
64
+ or,
65
+ policyPermissions,
66
+ } from './policy';
41
67
  export type { Actor, PolicyActorFields, RoleDef, RoleMap } from './roles';
42
68
  export {
43
- actorHas,
44
- actorPermissions,
45
69
  clearRoles,
46
70
  defineRoles,
47
71
  expandRoles,
48
72
  grantMatches,
73
+ roleDeclarationSites,
49
74
  roleDefinitions,
75
+ roleMapGeneration,
50
76
  rolesGranting,
51
77
  } from './roles';
52
78
  export type {
package/src/policy.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  // object be evaluated in an HTTP request, a job, a live query and an MCP tool without
3
3
  // any of them re-implementing the rule — one authz system, never two.
4
4
  import type { Ctx } from '@ultimat3/core';
5
+ import { actorHas } from './grant-index';
5
6
  import { assertPermission, type KnownPermission, type Permission } from './permissions';
6
- import { type Actor, actorHas } from './roles';
7
+ import type { Actor } from './roles';
7
8
 
8
9
  export type PolicyDecision =
9
10
  | { readonly allowed: true }
@@ -141,6 +142,22 @@ export const deny = <I = unknown, R = unknown>(
141
142
  },
142
143
  });
143
144
 
145
+ /**
146
+ * Every permission a policy tree references, deduped and sorted. This is the list a compliance
147
+ * report has to read: `label` renders a composite as `and(post:publish, org:administer)`, which
148
+ * is a sentence, not a permission — matching a grant against it reports every non-trivial rule's
149
+ * permissions as unenforced.
150
+ *
151
+ * A `not()` clause contributes its inner permissions too. The grant still decides the outcome,
152
+ * so "nothing references this permission" would be the false statement, not this one.
153
+ */
154
+ export const policyPermissions = <I = unknown, R = unknown>(
155
+ policy: Policy<I, R>,
156
+ ): readonly Permission[] => policy.permissions;
157
+
158
+ const flatten = (children: readonly { readonly permissions: readonly Permission[] }[]) =>
159
+ [...new Set(children.flatMap((child) => child.permissions))].sort();
160
+
144
161
  // Combinators hand `args` to every child untouched — `row` included. A clause that rewrote
145
162
  // the args would be the second authz shape all over again.
146
163
  const combined = <I, R>(
@@ -151,7 +168,7 @@ const combined = <I, R>(
151
168
  ): Policy<I, R> => ({
152
169
  kind,
153
170
  label,
154
- permissions: children.flatMap((child) => child.permissions),
171
+ permissions: flatten(children),
155
172
  children,
156
173
  run(args, recorder, depth = 0) {
157
174
  return record(recorder, this, depth, decide(args, recorder, depth + 1));
@@ -190,8 +207,19 @@ export const or = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy
190
207
  },
191
208
  );
192
209
 
210
+ /**
211
+ * Inverts a decision about an actor's grants — and only that.
212
+ *
213
+ * `X_UNAUTHENTICATED` is propagated, never inverted. "There is no actor" is not a fact about
214
+ * this actor's permissions, so flipping it makes `not(can('order:internal'))` read as *allow
215
+ * everyone who is not internal, anonymous callers first*. That is how a "public unless internal"
216
+ * route becomes a public internal route: the mistake is invisible in `and(can(…), not(can(…)))`
217
+ * because the first clause carries the authentication, and it ships the moment someone simplifies.
218
+ */
193
219
  export const not = <I, R = unknown>(policy: Policy<I, R>): Policy<I, R> =>
194
220
  combined('not', `not(${policy.label})`, [policy], (args, recorder, depth) => {
195
221
  const decision = policy.run(args, recorder, depth);
196
- return decision.allowed ? denied(`not(${policy.label}) — inner clause allowed`) : ALLOWED;
222
+ if (decision.allowed) return denied(`not(${policy.label}) — inner clause allowed`);
223
+ if (decision.code === 'X_UNAUTHENTICATED') return decision;
224
+ return ALLOWED;
197
225
  });
package/src/roles.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // Roles are sugar over permissions: a role grants a set, and may inherit others.
2
2
  // Everything is expanded to a flat permission set before any policy runs, so the
3
3
  // evaluator never has to reason about hierarchy — and a cycle is caught here, once.
4
+ // The per-actor flattening and its memo live in `grant-index.ts`; this file owns the map.
4
5
  import type { Actor as CoreActor } from '@ultimat3/core';
5
- import { type Permission, resourceOf } from './permissions';
6
+ import { roleRedefined } from './errors';
7
+ import { resourceOf } from './permissions';
6
8
 
7
9
  /**
8
10
  * The fields policy evaluation reads off an actor. `Actor` itself is core's; these
@@ -27,17 +29,81 @@ export interface RoleDef {
27
29
  export type RoleMap = Readonly<Record<string, RoleDef>>;
28
30
 
29
31
  let roleMap: RoleMap = {};
32
+ let sites: Readonly<Record<string, string>> = {};
33
+ let generation = 0;
30
34
 
35
+ /**
36
+ * Bumped by every write to the map. `grant-index.ts` memoises a flattened grant set against
37
+ * it, the same shape `@ultimat3/entity`'s `relationMap()` uses against `registryGeneration()`:
38
+ * a memo that cannot be invalidated by a later `defineRoles()` is a stale-authz bug.
39
+ */
40
+ export const roleMapGeneration = (): number => generation;
41
+
42
+ /** Frames inside this file — never the answer to "who declared this role?". */
43
+ const INTERNAL_FRAME = /declarationSite|defineRoles/;
44
+
45
+ const declarationSite = (): string => {
46
+ // Not a throw: the stack is the only place the declaring module's name exists at this point,
47
+ // and `X_ROLE_REDEFINED` is unactionable without both sides of the collision.
48
+ const stack = new Error().stack;
49
+ if (stack === undefined) return 'unknown site';
50
+ const frames = stack.split('\n').slice(1);
51
+ const frame = frames.find((line) => !INTERNAL_FRAME.test(line)) ?? frames[0] ?? '';
52
+ return frame.trim() === '' ? 'unknown site' : frame.trim();
53
+ };
54
+
55
+ const sameList = (left: readonly string[], right: readonly string[]): boolean => {
56
+ if (left.length !== right.length) return false;
57
+ const sortedRight = [...right].sort();
58
+ return [...left].sort().every((value, index) => value === sortedRight[index]);
59
+ };
60
+
61
+ /**
62
+ * A re-declaration of an identical role is a no-op, not a collision. That is what keeps the
63
+ * `defineRoles({ ...roleDefinitions(), … })` spelling both tracked apps already use legal:
64
+ * the spread hands every earlier role straight back.
65
+ */
66
+ const sameDefinition = (left: RoleDef, right: RoleDef): boolean =>
67
+ left === right ||
68
+ (left.description === right.description &&
69
+ sameList(left.grants, right.grants) &&
70
+ sameList(left.inherits ?? [], right.inherits ?? []));
71
+
72
+ /**
73
+ * MERGES into the app's one role map, and refuses a role two modules define differently.
74
+ *
75
+ * It used to replace, which made the map import-order-dependent: a second `defineRoles()` in a
76
+ * new feature folder silently deleted the first module's roles, every `can()` still typechecked,
77
+ * and every request 403'd on whichever bundler ordering CI happened to pick.
78
+ */
31
79
  export const defineRoles = <const M extends RoleMap>(map: M): M => {
32
- roleMap = map;
80
+ const site = declarationSite();
81
+ const merged: Record<string, RoleDef> = { ...roleMap };
82
+ const nextSites: Record<string, string> = { ...sites };
83
+ for (const [role, definition] of Object.entries(map)) {
84
+ const existing = merged[role];
85
+ if (existing !== undefined && !sameDefinition(existing, definition)) {
86
+ throw roleRedefined(role, sites[role] ?? 'unknown site', site);
87
+ }
88
+ merged[role] = definition;
89
+ nextSites[role] = nextSites[role] ?? site;
90
+ }
91
+ roleMap = merged;
92
+ sites = nextSites;
93
+ generation += 1;
33
94
  return map;
34
95
  };
35
96
 
36
97
  export const roleDefinitions = (): RoleMap => roleMap;
37
98
 
99
+ /** Where each role was first declared. Read by `X_ROLE_REDEFINED`, and by nothing else. */
100
+ export const roleDeclarationSites = (): Readonly<Record<string, string>> => sites;
101
+
38
102
  /** Test seam. */
39
103
  export const clearRoles = (): void => {
40
104
  roleMap = {};
105
+ sites = {};
106
+ generation += 1;
41
107
  };
42
108
 
43
109
  /**
@@ -69,22 +135,6 @@ export const grantMatches = (grant: string, wanted: string): boolean => {
69
135
  return false;
70
136
  };
71
137
 
72
- export const actorPermissions = (
73
- actor: Actor | null,
74
- map: RoleMap = roleMap,
75
- ): readonly string[] => {
76
- if (actor === null) return [];
77
- const direct = actor.permissions ?? [];
78
- const fromRoles = expandRoles(actor.roles ?? [], map);
79
- return [...new Set([...direct, ...fromRoles])].sort();
80
- };
81
-
82
- export const actorHas = (
83
- actor: Actor | null,
84
- permission: Permission,
85
- map: RoleMap = roleMap,
86
- ): boolean => actorPermissions(actor, map).some((grant) => grantMatches(grant, permission));
87
-
88
138
  /** For the `/_x` dashboard: which roles would satisfy a permission. */
89
139
  export const rolesGranting = (permission: string, map: RoleMap = roleMap): readonly string[] =>
90
140
  Object.keys(map)
package/src/surfaces.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  // allowed. Adding a fifth surface means adding an adapter HERE and nothing else — no
4
4
  // new policy model, no second authz path, no per-surface exceptions.
5
5
  //
6
+ // An adapter names its surface and does nothing else with it: the decision log is emitted
7
+ // inside `evaluate()`, so the fifth adapter inherits it instead of having to remember it.
8
+ //
6
9
  // The shapes are declared structurally rather than imported: `@ultimat3/http` is a
7
10
  // sibling tier, and jobs/realtime/mcp are higher tiers that import this package.
8
11
 
@@ -55,7 +58,7 @@ export const enforceHttp = <I, R = unknown>(
55
58
  policy: Policy<I, R>,
56
59
  args: EvaluateArgs<I, R>,
57
60
  ): HttpDenial | undefined => {
58
- const evaluation = evaluate(policy, args);
61
+ const evaluation = evaluate(policy, args, { surface: 'http' });
59
62
  if (evaluation.allowed) return undefined;
60
63
  return {
61
64
  surface: 'http',
@@ -73,7 +76,7 @@ export const enforceLive = <I, R = unknown>(
73
76
  policy: Policy<I, R>,
74
77
  args: EvaluateArgs<I, R>,
75
78
  ): LiveDenial | undefined => {
76
- const evaluation = evaluate(policy, args);
79
+ const evaluation = evaluate(policy, args, { surface: 'live' });
77
80
  if (evaluation.allowed) return undefined;
78
81
  return { surface: 'live', close: 4403, code: code(evaluation), reason: reason(evaluation) };
79
82
  };
@@ -82,7 +85,7 @@ export const enforceJob = <I, R = unknown>(
82
85
  policy: Policy<I, R>,
83
86
  args: EvaluateArgs<I, R>,
84
87
  ): JobDenial | undefined => {
85
- const evaluation = evaluate(policy, args);
88
+ const evaluation = evaluate(policy, args, { surface: 'job' });
86
89
  if (evaluation.allowed) return undefined;
87
90
  return {
88
91
  surface: 'job',
@@ -97,7 +100,7 @@ export const enforceMcp = <I, R = unknown>(
97
100
  policy: Policy<I, R>,
98
101
  args: EvaluateArgs<I, R>,
99
102
  ): McpDenial | undefined => {
100
- const evaluation = evaluate(policy, args);
103
+ const evaluation = evaluate(policy, args, { surface: 'mcp' });
101
104
  if (evaluation.allowed) return undefined;
102
105
  return {
103
106
  surface: 'mcp',
package/src/test-kit.ts CHANGED
@@ -39,12 +39,18 @@ export const policyMatrix = <I, R = unknown>(
39
39
  const rows = args.actors.map((entry): MatrixRow => {
40
40
  // Every field but the actor is forwarded verbatim: a matrix that dropped `row` would
41
41
  // report a row rule as denying everyone, and the table would lie.
42
- const evaluation = evaluate(policy, {
43
- input: args.input,
44
- actor: entry.actor,
45
- ...(args.row === undefined ? {} : { row: args.row }),
46
- ...(args.ctx === undefined ? {} : { ctx: args.ctx }),
47
- });
42
+ const evaluation = evaluate(
43
+ policy,
44
+ {
45
+ input: args.input,
46
+ actor: entry.actor,
47
+ ...(args.row === undefined ? {} : { row: args.row }),
48
+ ...(args.ctx === undefined ? {} : { ctx: args.ctx }),
49
+ },
50
+ // The `deciding` column IS the matrix; a production default that skips the trace would
51
+ // blank it, and `x policy explain` renders this table.
52
+ { trace: true },
53
+ );
48
54
  return {
49
55
  actor: entry.name,
50
56
  allowed: evaluation.allowed,