@ultimat3/policy 30.9.0 → 31.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 +13 -5
- package/README.md +52 -13
- package/package.json +2 -2
- package/src/decisions.ts +13 -77
- package/src/denial-parts.ts +48 -0
- package/src/errors.ts +20 -0
- package/src/evaluate.ts +7 -7
- package/src/guard-primitive.ts +125 -0
- package/src/index.ts +15 -35
package/CLAUDE.md
CHANGED
|
@@ -49,12 +49,18 @@ two differ, and it is why a surface that decides on input alone needs no edit.
|
|
|
49
49
|
hoisted here. `admitsAnonymous` in particular is EXACT for `actor === null` rather than a
|
|
50
50
|
heuristic, because `can()` short-circuits on the actor check before its predicate and
|
|
51
51
|
`allow()`/`deny()` ignore their arguments — no predicate is consulted, so the tree alone decides.
|
|
52
|
+
- **`action` and `query` reach authz through `guardPrimitive()`, one function** (`As of 2026-11`, v31).
|
|
53
|
+
The two tier-3 `policy-gate.ts` files were the same pipeline twice and drifted: a query's MCP
|
|
54
|
+
call was judged as `'job'`, an action's as `'mcp'`. The surface rule is
|
|
55
|
+
`PRIMITIVE_POLICY_SURFACES`, data, read with `Object.hasOwn` at both levels; it RETURNS the
|
|
56
|
+
denial (code, reason, shape) because the error class is the primitive's and this tier cannot
|
|
57
|
+
name it. A new primitive surface is a row there, never a branch in a caller.
|
|
52
58
|
- **One predicate shape.** A row-level rule reads `args.row`. Never pass a row through
|
|
53
59
|
`input`, and never add a per-surface args type.
|
|
54
60
|
- A policy is pure and synchronous. No I/O, no `await`. Load the row first, then decide.
|
|
55
61
|
- `reason` must be safe to log: name permissions and clauses, never row data or PII —
|
|
56
62
|
`row` being in scope changes nothing about what a denial is allowed to say. The same
|
|
57
|
-
guarantee is what a `
|
|
63
|
+
guarantee is what a `policy.decision` event inherits: `decisions.ts` carries the label, the
|
|
58
64
|
deciding clause and the actor's identifiers, and **never `row` or `input`**.
|
|
59
65
|
- A missing policy is refused by the **type system**, not by a throw: `ActionDef.policy`
|
|
60
66
|
is a required field in `@ultimat3/action`, so an action without one does not compile.
|
|
@@ -176,8 +182,10 @@ reappearing there is a failing test.
|
|
|
176
182
|
| `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording, and `policyPermissions` |
|
|
177
183
|
| `policy-anonymous.ts` | `admitsAnonymous` — the one question a SURFACE asks of a built tree, apart from the file that builds them |
|
|
178
184
|
| `evaluate.ts` | the single entry point; builds the trace, emits the one decision event |
|
|
179
|
-
| `decisions.ts` | the `
|
|
185
|
+
| `decisions.ts` | the `policy.decision` bus event — built only while subscribed, one call site, never PII. No slot (`X_INSTRUMENT_SLOT_FORBIDDEN`) |
|
|
180
186
|
| `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
|
|
187
|
+
| `guard-primitive.ts` | `guardPrimitive` — the one gate `action` and `query` run, and the per-primitive surface table |
|
|
188
|
+
| `denial-parts.ts` | `denialCode` / `denialReason` — any `SurfaceDenial` read back as one code and one reason |
|
|
181
189
|
| `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
|
|
182
190
|
| `declaration-site.ts` | the caller's stack frame a role or permission declaration records |
|
|
183
191
|
| `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
|
|
@@ -194,13 +202,13 @@ with 10k watchers is 10k evaluations in one tick. Two consequences, both load-be
|
|
|
194
202
|
re-reads the user row every request and mints a fresh frozen actor, so a revoked role still
|
|
195
203
|
takes effect on the next request and the entry dies with it. **Never cache an actor, or a
|
|
196
204
|
grant set, across requests** — that is the stale-authz window the framework does not have.
|
|
197
|
-
- The trace is opt-in: on outside production, and in production only
|
|
198
|
-
|
|
205
|
+
- The trace is opt-in: on outside production, and in production only while `policy.decision` has a
|
|
206
|
+
subscriber. `evaluate(policy, args, { trace: true })` forces it — `policyMatrix()` does,
|
|
199
207
|
because `deciding` *is* the matrix.
|
|
200
208
|
|
|
201
209
|
## Commands
|
|
202
210
|
|
|
203
211
|
```
|
|
204
|
-
bun test packages/policy
|
|
212
|
+
bun test ./packages/policy/
|
|
205
213
|
bun run --filter @ultimat3/policy typecheck
|
|
206
214
|
```
|
package/README.md
CHANGED
|
@@ -119,7 +119,9 @@ arguments, so no predicate is ever consulted and the tree alone decides. `true`
|
|
|
119
119
|
## Four surfaces, four adapters, one rule
|
|
120
120
|
|
|
121
121
|
`surfaces.ts` is the proof. Each adapter evaluates and maps a denial to that surface's
|
|
122
|
-
error shape; allowed returns `undefined`.
|
|
122
|
+
error shape; allowed returns `undefined`. The barrel exports `enforceHttp` and
|
|
123
|
+
`enforce(surface, policy, args)`; the live, job and MCP adapters are reached through `enforce` only
|
|
124
|
+
(off `.` since 31.0.0).
|
|
123
125
|
|
|
124
126
|
| Adapter | Denial shape |
|
|
125
127
|
|---|---|
|
|
@@ -147,6 +149,39 @@ it inherits `Object.prototype` — `enforce('valueOf' as Surface, …)` used to
|
|
|
147
149
|
`Object.prototype.valueOf` with the table as its receiver and return a truthy value, so an authz
|
|
148
150
|
dispatch failed **closed with a `SurfaceDenial` no caller could read**.
|
|
149
151
|
|
|
152
|
+
## One guard for action and query
|
|
153
|
+
|
|
154
|
+
`guardPrimitive(kind, policy, subject, surface)` is the gate `@ultimat3/action` and
|
|
155
|
+
`@ultimat3/query` both run — siblings at tier 3 that cannot share a file, so it lives here.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import type { Actor } from '@ultimat3/core';
|
|
159
|
+
import { can, guardPrimitive } from '@ultimat3/policy';
|
|
160
|
+
|
|
161
|
+
declare const actor: Actor | null;
|
|
162
|
+
const refused = guardPrimitive(
|
|
163
|
+
'query',
|
|
164
|
+
can('post:read'),
|
|
165
|
+
{ stage: 'actor', name: 'postFeed', actor },
|
|
166
|
+
'mcp',
|
|
167
|
+
);
|
|
168
|
+
// `@ultimat3/query` throws its own class from the answer: `new QueryDeniedError(refused)`.
|
|
169
|
+
if (refused !== undefined) console.log(refused.code, refused.reason);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| Field | Meaning |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `kind` | `'action'` or `'query'` — picks the surface table |
|
|
175
|
+
| `subject.stage` | `'actor'`: before the input parse, only `can()`/`allow()`/`deny()` decide, undecided passes · `'input'`: the full evaluation, with `input` and the loaded `row` |
|
|
176
|
+
| `surface` | where the primitive ran: action `server · http · mcp · job`, query `server · http · mcp · live` |
|
|
177
|
+
| returns | `undefined` to proceed, or `{ kind, name, surface, code, reason, denial }` |
|
|
178
|
+
|
|
179
|
+
`PRIMITIVE_POLICY_SURFACES` is the surface rule as data: `server` is judged as `job`, `mcp` as
|
|
180
|
+
`mcp` for **both** primitives (a read was judged as `job` until v31). A surface the primitive does
|
|
181
|
+
not run on is `X_POLICY_SURFACE_UNKNOWN`, never a decision. `denialCode(denial)` /
|
|
182
|
+
`denialReason(denial)` read any `SurfaceDenial` back as one code and one reason — the MCP text
|
|
183
|
+
without its `<code>: ` prefix.
|
|
184
|
+
|
|
150
185
|
## Permissions and roles
|
|
151
186
|
|
|
152
187
|
`definePermissions(['post:publish', ...])` gives a typed set; augmenting
|
|
@@ -183,26 +218,30 @@ editor deny post:read predicate returned false
|
|
|
183
218
|
viewer deny actor lacks post:publish
|
|
184
219
|
```
|
|
185
220
|
|
|
186
|
-
Building it is opt-in: on outside production, and in production only
|
|
187
|
-
|
|
221
|
+
Building it is opt-in: on outside production, and in production only while a `policy.decision`
|
|
222
|
+
subscriber is listening. A live query evaluates policy per subscriber on every change event, so an unread
|
|
188
223
|
`TraceEntry[]` per evaluation is real allocation on the busiest path there is. Force it with
|
|
189
224
|
`evaluate(policy, args, { trace: true })`.
|
|
190
225
|
|
|
191
226
|
## The decision log
|
|
192
227
|
|
|
193
228
|
```ts
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
229
|
+
import { subscribe } from '@ultimat3/core';
|
|
230
|
+
|
|
231
|
+
// { label, allowed, code, reason, actorId, actorKind, orgId, surface, deciding }
|
|
232
|
+
export const stopAudit = subscribe('policy.decision', ({ payload }) => {
|
|
233
|
+
void payload.label;
|
|
198
234
|
});
|
|
199
235
|
```
|
|
200
236
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
237
|
+
`As of 2026-11` (v31): every evaluation is one `policy.decision` event on core's instrumentation
|
|
238
|
+
bus (`PolicyDecisionEvent`, re-exported here). Nothing is built until something subscribes, it is
|
|
239
|
+
emitted from **one place** — inside `evaluate()`, so a fifth surface inherits it — and it records
|
|
240
|
+
the **allow** as well as the denial, which is the half an access review actually asks for. It never
|
|
241
|
+
carries `row` or `input`: `reason` is safe to log by construction, and the event inherits that
|
|
242
|
+
guarantee. Every subscriber hears it — there is no slot to replace. A subscriber's throw is
|
|
243
|
+
contained by the bus, as on every event: an observer is not an authorization input, so it can
|
|
244
|
+
neither deny nor break the evaluation.
|
|
206
245
|
|
|
207
246
|
## Errors
|
|
208
247
|
|
|
@@ -220,7 +259,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
220
259
|
|
|
221
260
|
| Class | Code | Declared in |
|
|
222
261
|
|---|---|---|
|
|
223
|
-
| `PolicyError` | any `PolicyErrorCode`
|
|
262
|
+
| `PolicyError` | any `PolicyErrorCode` | `src/errors.ts` |
|
|
224
263
|
|
|
225
264
|
## Boundaries
|
|
226
265
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/policy",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "31.0.0",
|
|
4
4
|
"description": "The one authz rule, evaluated identically in every surface",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -42,6 +42,6 @@
|
|
|
42
42
|
"test": "bun test"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@ultimat3/core": "
|
|
45
|
+
"@ultimat3/core": "31.0.0"
|
|
46
46
|
}
|
|
47
47
|
}
|
package/src/decisions.ts
CHANGED
|
@@ -1,90 +1,26 @@
|
|
|
1
|
-
// The authz decision log
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The authz decision log: every evaluation, allowed ones included, is a `policy.decision` event on
|
|
2
|
+
// the instrumentation bus — because the question after an incident is "who did this, and which
|
|
3
|
+
// rule let them?", and an allowed decision used to leave no trace on any of the four surfaces.
|
|
4
|
+
// A SIEM exporter, an append-only table or a log line all arrive as one `subscribe()`.
|
|
5
5
|
//
|
|
6
6
|
// PII rule, inherited and not negotiable: an event carries the label, the clause and the actor's
|
|
7
7
|
// identifiers. Never `row`, never `input`. `reason` is already safe to log by construction
|
|
8
8
|
// (policy/CLAUDE.md) and this is the guarantee that keeps it that way.
|
|
9
|
-
import {
|
|
10
|
-
import type { Surface } from './surfaces';
|
|
9
|
+
import { instrument, type PolicyDecisionEvent, subscribed } from '@ultimat3/core';
|
|
11
10
|
|
|
12
|
-
|
|
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
|
-
};
|
|
11
|
+
const DECISION = 'policy.decision';
|
|
49
12
|
|
|
50
13
|
/**
|
|
51
|
-
* Read by `evaluate()` before it builds an event, and by the trace default: a
|
|
52
|
-
* reason to keep building a trace in production.
|
|
14
|
+
* Read by `evaluate()` before it builds an event, and by the trace default: a subscriber is the
|
|
15
|
+
* one reason to keep building a trace in production. One map read when nobody listens.
|
|
53
16
|
*/
|
|
54
|
-
export const
|
|
17
|
+
export const decisionObserved = (): boolean => subscribed(DECISION);
|
|
55
18
|
|
|
56
19
|
/**
|
|
57
|
-
* Called from exactly ONE place — inside `evaluate()`, so a fifth surface inherits the log the
|
|
58
|
-
*
|
|
59
|
-
*
|
|
20
|
+
* Called from exactly ONE place — inside `evaluate()`, so a fifth surface inherits the log the day
|
|
21
|
+
* it is added rather than the day someone remembers to wire it. A throwing subscriber is contained
|
|
22
|
+
* by the bus, as on every event: an observer is not an authorization input and cannot deny.
|
|
60
23
|
*/
|
|
61
24
|
export const emitDecision = (event: PolicyDecisionEvent): void => {
|
|
62
|
-
|
|
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
|
-
};
|
|
25
|
+
instrument(DECISION, event);
|
|
90
26
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The code and the reason a surface-shaped denial carries, read back out of whichever shape the
|
|
2
|
+
// adapter rendered. Here once: `@ultimat3/action` and `@ultimat3/query` each kept a copy, and both
|
|
3
|
+
// copies read an MCP denial's whole text as its reason, so the cause said the code twice.
|
|
4
|
+
|
|
5
|
+
import { assertNever } from '@ultimat3/core';
|
|
6
|
+
import type { SurfaceDenial } from './surfaces';
|
|
7
|
+
|
|
8
|
+
/** The `X_*` code the policy decided with — the same one on every surface. */
|
|
9
|
+
export function denialCode(denial: SurfaceDenial): string {
|
|
10
|
+
switch (denial.surface) {
|
|
11
|
+
case 'http':
|
|
12
|
+
return denial.problem.code;
|
|
13
|
+
case 'live':
|
|
14
|
+
case 'job':
|
|
15
|
+
return denial.code;
|
|
16
|
+
case 'mcp':
|
|
17
|
+
return mcpParts(denial.content[0]?.text).code;
|
|
18
|
+
default:
|
|
19
|
+
return assertNever(denial);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The policy's reason, safe to log by construction — never prefixed with the code. */
|
|
24
|
+
export function denialReason(denial: SurfaceDenial): string {
|
|
25
|
+
switch (denial.surface) {
|
|
26
|
+
case 'http':
|
|
27
|
+
return denial.problem.detail;
|
|
28
|
+
case 'live':
|
|
29
|
+
case 'job':
|
|
30
|
+
return denial.reason;
|
|
31
|
+
case 'mcp':
|
|
32
|
+
return mcpParts(denial.content[0]?.text).reason;
|
|
33
|
+
default:
|
|
34
|
+
return assertNever(denial);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `enforceMcp` writes `<code>: <reason>`, and a code never holds `': '`, so the first separator
|
|
40
|
+
* splits it exactly. A text without one is a reason with no code: `X_FORBIDDEN`, the default
|
|
41
|
+
* every adapter falls back to.
|
|
42
|
+
*/
|
|
43
|
+
function mcpParts(text: string | undefined): { readonly code: string; readonly reason: string } {
|
|
44
|
+
if (text === undefined) return { code: 'X_FORBIDDEN', reason: 'denied' };
|
|
45
|
+
const at = text.indexOf(': ');
|
|
46
|
+
if (at <= 0) return { code: 'X_FORBIDDEN', reason: text };
|
|
47
|
+
return { code: text.slice(0, at), reason: text.slice(at + 2) };
|
|
48
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -243,3 +243,23 @@ export const surfaceUnknown = (surface: unknown, known: readonly string[]): Poli
|
|
|
243
243
|
cause: `enforce() was handed surface ${renderCauseValue(surface)}, which no policy adapter answers to`,
|
|
244
244
|
fix: `pass one of ${known.join(', ')} — e.g. enforce('${known[0] ?? 'http'}', policy, args)`,
|
|
245
245
|
});
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `guardPrimitive()` was handed a surface its primitive does not run on — an action on `live`, a
|
|
249
|
+
* query on `job` — or a kind that is neither. The same code as `surfaceUnknown`, because it is the
|
|
250
|
+
* same fault one layer up: no adapter answers. Both values are rendered, never interpolated: the
|
|
251
|
+
* call site this exists for is a JS host or a config table no type reached.
|
|
252
|
+
*/
|
|
253
|
+
export const primitiveSurfaceUnknown = (
|
|
254
|
+
kind: unknown,
|
|
255
|
+
surface: unknown,
|
|
256
|
+
known: readonly string[],
|
|
257
|
+
): PolicyError =>
|
|
258
|
+
new PolicyError({
|
|
259
|
+
code: 'X_POLICY_SURFACE_UNKNOWN',
|
|
260
|
+
cause: `guardPrimitive() was handed surface ${renderCauseValue(surface)} for kind ${renderCauseValue(kind)}, which that primitive does not run on`,
|
|
261
|
+
fix:
|
|
262
|
+
known.length === 0
|
|
263
|
+
? "guardPrimitive('action', policy, subject, 'server') # the kind is 'action' or 'query'"
|
|
264
|
+
: `guardPrimitive(kind, policy, subject, '${known[0]}') # the surface is one of ${known.map((name) => `'${name}'`).join(', ')}`,
|
|
265
|
+
});
|
package/src/evaluate.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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
|
-
// It is also the one place a decision
|
|
4
|
+
// It is also the one place a `policy.decision` event is emitted — allowed decisions
|
|
5
5
|
// included, since "who was let in" is the half an audit actually needs.
|
|
6
6
|
import { type Ctx, DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { decisionObserved, emitDecision } from './decisions';
|
|
8
8
|
import type { Policy, PolicyDecision, TraceEntry } from './policy';
|
|
9
9
|
import type { Actor } from './roles';
|
|
10
10
|
import type { Surface } from './surfaces';
|
|
@@ -24,12 +24,12 @@ export interface EvaluateArgs<I, R = unknown> {
|
|
|
24
24
|
|
|
25
25
|
export interface EvaluateOptions {
|
|
26
26
|
/**
|
|
27
|
-
* Build the trace. Defaults to on outside production, and to on in production only
|
|
28
|
-
* `
|
|
27
|
+
* Build the trace. Defaults to on outside production, and to on in production only while
|
|
28
|
+
* `policy.decision` has a subscriber — a `TraceEntry[]` per evaluation is real allocation on the
|
|
29
29
|
* live-query path, where one write fans out to one evaluation per subscriber.
|
|
30
30
|
*/
|
|
31
31
|
readonly trace?: boolean | undefined;
|
|
32
|
-
/** Which adapter asked. Carried
|
|
32
|
+
/** Which adapter asked. Carried on the event; the decision itself never depends on it. */
|
|
33
33
|
readonly surface?: Surface | undefined;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -53,7 +53,7 @@ let outsideProduction: boolean | undefined;
|
|
|
53
53
|
*/
|
|
54
54
|
const traceByDefault = (): boolean => {
|
|
55
55
|
outsideProduction ??= (tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT) !== 'production';
|
|
56
|
-
return outsideProduction ||
|
|
56
|
+
return outsideProduction || decisionObserved();
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
/** Test seam: re-reads the environment on the next evaluation. */
|
|
@@ -91,7 +91,7 @@ export const evaluate = <I, R = unknown>(
|
|
|
91
91
|
) ??
|
|
92
92
|
trace.find(agrees) ??
|
|
93
93
|
null;
|
|
94
|
-
if (
|
|
94
|
+
if (decisionObserved()) {
|
|
95
95
|
emitDecision({
|
|
96
96
|
label: policy.label,
|
|
97
97
|
allowed: decision.allowed,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The one policy guard `action` and `query` both run: which policy surface each projection is
|
|
2
|
+
// judged on, the actor-only stage before the input parse, the full stage after it, and the denial
|
|
3
|
+
// read back as one code and one reason. Tier 3 siblings cannot share a file, so it lives here.
|
|
4
|
+
|
|
5
|
+
import type { Ctx } from '@ultimat3/core';
|
|
6
|
+
import { denialCode, denialReason } from './denial-parts';
|
|
7
|
+
import { primitiveSurfaceUnknown } from './errors';
|
|
8
|
+
import type { Policy } from './policy';
|
|
9
|
+
import { enforceBeforeInput } from './pre-input';
|
|
10
|
+
import type { Actor } from './roles';
|
|
11
|
+
import { enforce, type Surface, type SurfaceDenial } from './surfaces';
|
|
12
|
+
|
|
13
|
+
/** The two primitives that carry a policy. A mutator is an action; a route asks `evaluate()`. */
|
|
14
|
+
export type GuardedPrimitive = 'action' | 'query';
|
|
15
|
+
|
|
16
|
+
/** Where each primitive runs. `'server'` is in-process app code; `live` is a read's alone. */
|
|
17
|
+
export interface PrimitiveSurfaces {
|
|
18
|
+
readonly action: 'server' | 'http' | 'mcp' | 'job';
|
|
19
|
+
readonly query: 'server' | 'http' | 'mcp' | 'live';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PrimitiveSurface<K extends GuardedPrimitive = GuardedPrimitive> = PrimitiveSurfaces[K];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The surface rules, as data. A direct server call has no request and no response to shape, so it
|
|
26
|
+
* is judged as a job. An MCP call is an agent's on BOTH primitives: a read was judged as `'job'`
|
|
27
|
+
* until v31, so its decision events and its denial shape disagreed with an MCP action's.
|
|
28
|
+
*/
|
|
29
|
+
export const PRIMITIVE_POLICY_SURFACES = Object.freeze<{
|
|
30
|
+
readonly [K in GuardedPrimitive]: Readonly<Record<PrimitiveSurfaces[K], Surface>>;
|
|
31
|
+
}>({
|
|
32
|
+
action: Object.freeze<Record<PrimitiveSurfaces['action'], Surface>>({
|
|
33
|
+
server: 'job',
|
|
34
|
+
http: 'http',
|
|
35
|
+
mcp: 'mcp',
|
|
36
|
+
job: 'job',
|
|
37
|
+
}),
|
|
38
|
+
query: Object.freeze<Record<PrimitiveSurfaces['query'], Surface>>({
|
|
39
|
+
server: 'job',
|
|
40
|
+
http: 'http',
|
|
41
|
+
mcp: 'mcp',
|
|
42
|
+
live: 'live',
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
interface SubjectBase {
|
|
47
|
+
/** The registered name of the primitive — what the denial and `x policy explain` name. */
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly actor: Actor | null;
|
|
50
|
+
readonly ctx?: Ctx | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Before the input parse: only who is asking. Undecided passes to the `'input'` stage. */
|
|
54
|
+
export interface ActorStageSubject extends SubjectBase {
|
|
55
|
+
readonly stage: 'actor';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** After the parse: the input, and the loaded row a row-level rule decides about. */
|
|
59
|
+
export interface InputStageSubject<I = unknown, R = unknown> extends SubjectBase {
|
|
60
|
+
readonly stage: 'input';
|
|
61
|
+
readonly input: I;
|
|
62
|
+
/** Omitted when nothing was loaded; `evaluate()` hands the predicate `null`. */
|
|
63
|
+
readonly row?: R | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type GuardSubject<I = unknown, R = unknown> = ActorStageSubject | InputStageSubject<I, R>;
|
|
67
|
+
|
|
68
|
+
/** A refusal, already read: the adopting package throws its own error class from it. */
|
|
69
|
+
export interface PrimitiveDenial<K extends GuardedPrimitive = GuardedPrimitive> {
|
|
70
|
+
readonly kind: K;
|
|
71
|
+
readonly name: string;
|
|
72
|
+
readonly surface: PrimitiveSurface<K>;
|
|
73
|
+
readonly code: string;
|
|
74
|
+
readonly reason: string;
|
|
75
|
+
/** The surface-shaped denial — a live socket closes 4403 off it, HTTP renders its problem. */
|
|
76
|
+
readonly denial: SurfaceDenial;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide once and return the refusal, or `undefined` to proceed. Returned rather than thrown: the
|
|
81
|
+
* error class is the primitive's (`ActionDeniedError`, `QueryDeniedError`) and this tier cannot
|
|
82
|
+
* name it. The decision event is `evaluate()`'s, emitted once, on the mapped policy surface.
|
|
83
|
+
*/
|
|
84
|
+
export function guardPrimitive<K extends GuardedPrimitive, I, R>(
|
|
85
|
+
kind: K,
|
|
86
|
+
policy: Policy<I, R>,
|
|
87
|
+
subject: GuardSubject<I, R>,
|
|
88
|
+
surface: PrimitiveSurface<K>,
|
|
89
|
+
): PrimitiveDenial<K> | undefined {
|
|
90
|
+
const target = policySurfaceOf(kind, surface);
|
|
91
|
+
const denial =
|
|
92
|
+
subject.stage === 'actor'
|
|
93
|
+
? enforceBeforeInput(target, policy, actorArgs(subject))
|
|
94
|
+
: enforce(target, policy, {
|
|
95
|
+
input: subject.input,
|
|
96
|
+
actor: subject.actor,
|
|
97
|
+
...(subject.row === undefined ? {} : { row: subject.row }),
|
|
98
|
+
...(subject.ctx === undefined ? {} : { ctx: subject.ctx }),
|
|
99
|
+
});
|
|
100
|
+
if (denial === undefined) return undefined;
|
|
101
|
+
return {
|
|
102
|
+
kind,
|
|
103
|
+
name: subject.name,
|
|
104
|
+
surface,
|
|
105
|
+
code: denialCode(denial),
|
|
106
|
+
reason: denialReason(denial),
|
|
107
|
+
denial,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function actorArgs(subject: SubjectBase): { actor: Actor | null; ctx?: Ctx } {
|
|
112
|
+
return subject.ctx === undefined
|
|
113
|
+
? { actor: subject.actor }
|
|
114
|
+
: { actor: subject.actor, ctx: subject.ctx };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `Object.hasOwn` at both levels, for the reason `enforce()` gives: an index answers `valueOf`. */
|
|
118
|
+
function policySurfaceOf(kind: GuardedPrimitive, surface: string): Surface {
|
|
119
|
+
if (!Object.hasOwn(PRIMITIVE_POLICY_SURFACES, kind))
|
|
120
|
+
throw primitiveSurfaceUnknown(kind, surface, []);
|
|
121
|
+
const table: Readonly<Record<string, Surface>> = PRIMITIVE_POLICY_SURFACES[kind];
|
|
122
|
+
if (!Object.hasOwn(table, surface))
|
|
123
|
+
throw primitiveSurfaceUnknown(kind, surface, Object.keys(table));
|
|
124
|
+
return table[surface] as Surface;
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,44 +1,33 @@
|
|
|
1
1
|
// The public surface of @ultimat3/policy. Explicit, never `export *`.
|
|
2
|
-
|
|
3
|
-
export {
|
|
4
|
-
decisionSinkInstalled,
|
|
5
|
-
memoryDecisionSink,
|
|
6
|
-
noopDecisionSink,
|
|
7
|
-
resetDecisionSink,
|
|
8
|
-
setDecisionSink,
|
|
9
|
-
} from './decisions';
|
|
2
|
+
// The `policy.decision` payload is core's (the bus is tier 0); re-exported, never a second shape.
|
|
3
|
+
export type { PolicyDecisionEvent } from '@ultimat3/core';
|
|
10
4
|
export type { DefinePolicyInput } from './define';
|
|
11
5
|
export { definePolicy } from './define';
|
|
6
|
+
export { denialCode, denialReason } from './denial-parts';
|
|
12
7
|
export type { PolicyErrorCode } from './errors';
|
|
13
8
|
export {
|
|
14
9
|
denialError,
|
|
15
|
-
emptyClauseList,
|
|
16
10
|
POLICY_BORROWED_ERROR_CODES,
|
|
17
|
-
POLICY_ERROR_CODES,
|
|
18
|
-
POLICY_ERROR_TITLES,
|
|
19
11
|
PolicyDenialError,
|
|
20
12
|
PolicyError,
|
|
21
13
|
permissionUnknown,
|
|
22
14
|
policyForbidden,
|
|
23
15
|
policyMissing,
|
|
24
|
-
roleRedefined,
|
|
25
16
|
} from './errors';
|
|
26
17
|
export type { EvaluateArgs, EvaluateOptions, PolicyEvaluation } from './evaluate';
|
|
27
|
-
export {
|
|
28
|
-
codeOf,
|
|
29
|
-
evaluate,
|
|
30
|
-
explain,
|
|
31
|
-
reasonOf,
|
|
32
|
-
renderTrace,
|
|
33
|
-
resetPolicyTracing,
|
|
34
|
-
} from './evaluate';
|
|
18
|
+
export { codeOf, evaluate, explain, reasonOf } from './evaluate';
|
|
35
19
|
export { actorHas, actorPermissions } from './grant-index';
|
|
36
20
|
export type {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
21
|
+
ActorStageSubject,
|
|
22
|
+
GuardedPrimitive,
|
|
23
|
+
GuardSubject,
|
|
24
|
+
InputStageSubject,
|
|
25
|
+
PrimitiveDenial,
|
|
26
|
+
PrimitiveSurface,
|
|
27
|
+
PrimitiveSurfaces,
|
|
28
|
+
} from './guard-primitive';
|
|
29
|
+
export { guardPrimitive, PRIMITIVE_POLICY_SURFACES } from './guard-primitive';
|
|
30
|
+
export type { KnownPermission, Permission, PermissionRegistry, PermissionSet } from './permissions';
|
|
42
31
|
export {
|
|
43
32
|
assertPermission,
|
|
44
33
|
clearPermissions,
|
|
@@ -48,7 +37,6 @@ export {
|
|
|
48
37
|
permissionDeclarationSites,
|
|
49
38
|
resourceOf,
|
|
50
39
|
restorePermissions,
|
|
51
|
-
verbOf,
|
|
52
40
|
} from './permissions';
|
|
53
41
|
export type {
|
|
54
42
|
Policy,
|
|
@@ -83,7 +71,6 @@ export {
|
|
|
83
71
|
restoreRoles,
|
|
84
72
|
roleDeclarationSites,
|
|
85
73
|
roleDefinitions,
|
|
86
|
-
roleMapGeneration,
|
|
87
74
|
rolesGranting,
|
|
88
75
|
} from './roles';
|
|
89
76
|
export type {
|
|
@@ -95,13 +82,6 @@ export type {
|
|
|
95
82
|
Surface,
|
|
96
83
|
SurfaceDenial,
|
|
97
84
|
} from './surfaces';
|
|
98
|
-
export {
|
|
99
|
-
assertAllowed,
|
|
100
|
-
enforce,
|
|
101
|
-
enforceHttp,
|
|
102
|
-
enforceJob,
|
|
103
|
-
enforceLive,
|
|
104
|
-
enforceMcp,
|
|
105
|
-
} from './surfaces';
|
|
85
|
+
export { assertAllowed, enforce, enforceHttp } from './surfaces';
|
|
106
86
|
export type { MatrixArgs, MatrixRow, NamedActor, PolicyMatrix } from './test-kit';
|
|
107
87
|
export { policyMatrix, testActor } from './test-kit';
|