@ultimat3/policy 12.0.0 → 14.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 +29 -3
- package/README.md +13 -3
- package/package.json +2 -2
- package/src/errors.ts +74 -1
- package/src/index.ts +1 -0
- package/src/policy-anonymous.ts +8 -2
- package/src/policy.ts +38 -7
package/CLAUDE.md
CHANGED
|
@@ -73,9 +73,29 @@ two differ, and it is why a surface that decides on input alone needs no edit.
|
|
|
73
73
|
process — so a clear in one test file is permanent for every file after it, whose own `import` is a
|
|
74
74
|
cache hit that declares nothing. `restoreRoles` takes the declaration sites too: `defineRoles()` derives
|
|
75
75
|
them from the CALLER's stack, so restoring through it would make `X_ROLE_REDEFINED` name the harness.
|
|
76
|
-
- **`
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
- **`and()` and `or()` REFUSE an empty clause list** (`X_POLICY_CLAUSE_EMPTY`, `As of 2026-08-25`),
|
|
77
|
+
at the call that builds them. An empty `and()` found nothing to deny and answered ALLOWED, so
|
|
78
|
+
`and(...requiredCaps.map(can))` over a list that filtered to nothing — a config-driven or
|
|
79
|
+
per-tenant rule table — admitted an **anonymous** caller on all four surfaces, with `meta.auth`
|
|
80
|
+
deriving from `admitsAnonymous` so `@ultimat3/http` did not 401 first either, and no diagnostic:
|
|
81
|
+
the label renders as `and()`. `allow('public')` is the explicit spelling, so refusing costs a
|
|
82
|
+
caller nothing they cannot say another way. `or()` is refused for symmetry and for axiom 1 rather
|
|
83
|
+
than for safety — it fails closed, but with "no clause allowed this actor", a reason naming no
|
|
84
|
+
clause; `deny('<reason>')` carries one. Refused where it is WRITTEN, the same call
|
|
85
|
+
`@ultimat3/scraping`'s `allowHosts: []` and `discriminated-union.ts`'s unroutable member make.
|
|
86
|
+
- **`not()` never inverts `X_UNAUTHENTICATED`, and `or()` is what makes that true of a TREE**
|
|
87
|
+
(`As of 2026-08-25`). A null actor is not a fact about this actor's grants; inverting it makes
|
|
88
|
+
`not(can('order:internal'))` a public door into the internal one. Any denial carrying that code
|
|
89
|
+
propagates unchanged — but the rule held only while `not`'s DIRECT child was a `can()`:
|
|
90
|
+
`not(or(can('order:internal'), deny('read-only mode')))` **allowed `actor: null`**, because `or`
|
|
91
|
+
reported the LAST denial and `deny`'s `X_FORBIDDEN` overwrote the code `not()` had to recognise.
|
|
92
|
+
So `or` now reports a denial carrying `X_UNAUTHENTICATED` over a later one that does not, and
|
|
93
|
+
`policy-anonymous.ts`'s `or` walk mirrors it. **`and` needs no such rule** — it short-circuits, so
|
|
94
|
+
the denial it reports IS the deciding one. The other candidate repair, `not()` re-reading
|
|
95
|
+
`args.actor === null` itself, was refused: it would deny an anonymous caller under `not(deny(…))`,
|
|
96
|
+
which this file and `policy-anonymous.ts` both document as ALLOWED, and it states the wrong rule —
|
|
97
|
+
`not` inverts a decision about grants, and whether one was made without an actor is `or`'s to
|
|
98
|
+
report.
|
|
79
99
|
- **`defineRoles()` merges** and refuses a role two modules define differently
|
|
80
100
|
(`X_ROLE_REDEFINED`, naming both declaration sites). A re-declaration of an *identical*
|
|
81
101
|
role is a no-op, which is what keeps `defineRoles({ ...roleDefinitions(), … })` legal.
|
|
@@ -112,6 +132,12 @@ two differ, and it is why a surface that decides on input alone needs no edit.
|
|
|
112
132
|
- No `any`. Never throw a bare `Error` — use `errors.ts`.
|
|
113
133
|
- **This package owns `X_FORBIDDEN`** and registers its title with core. `http`, `auth`
|
|
114
134
|
and every surface adapter reuse the code and must not re-register it.
|
|
135
|
+
- **Every owned code is classified `terminal`, and every one is LISTED** (`As of 2026-08-25`).
|
|
136
|
+
`classifyThrown` reads an unregistered code carrying `terminal` as UNCLASSIFIED, so the attempt
|
|
137
|
+
count governs and a job spends its whole retry policy re-proving a denial — the cost
|
|
138
|
+
`@ultimat3/jobs`' webhook block is written up against. Core's own `ErrorRetry` doc names "a
|
|
139
|
+
permission denial" as the canonical `terminal` case and `X_FORBIDDEN` was not classified at all.
|
|
140
|
+
`errors.test.ts` pins the whole list, so a new code with no classification is a failing test.
|
|
115
141
|
|
|
116
142
|
## The one authz rule — and the one honest exception
|
|
117
143
|
|
package/README.md
CHANGED
|
@@ -70,14 +70,24 @@ missing row to `null`. A surface that has no row passes `{ input, actor, ctx }`
|
|
|
70
70
|
|---|---|
|
|
71
71
|
| `can(p, predicate?)` | permission first, then the row-level predicate |
|
|
72
72
|
| `allow()` / `deny(reason)` | terminal; `allow()` is how "public" is said out loud |
|
|
73
|
-
| `and(...)` | first denial wins, its reason is the reason |
|
|
74
|
-
| `or(...)` | first allowance wins; otherwise the last denial is
|
|
73
|
+
| `and(...)` | first denial wins, its reason is the reason; **no clauses is `X_POLICY_CLAUSE_EMPTY`** |
|
|
74
|
+
| `or(...)` | first allowance wins; otherwise the last denial — except one carrying `X_UNAUTHENTICATED`, which outranks a later one. **No clauses is `X_POLICY_CLAUSE_EMPTY`** |
|
|
75
75
|
| `not(p)` | inverts — except `X_UNAUTHENTICATED`, which propagates unchanged |
|
|
76
76
|
|
|
77
77
|
`not()` never turns "there is no actor" into an allow. `can()` denies a null actor with
|
|
78
78
|
`X_UNAUTHENTICATED`, and inverting that would make `not(can('order:internal'))` — the natural
|
|
79
79
|
simplification of `and(can('order:read'), not(can('order:internal')))` — a public door into the
|
|
80
|
-
internal one.
|
|
80
|
+
internal one. `or()` is what makes that true of a whole TREE rather than only of `not`'s direct
|
|
81
|
+
child: it used to report the LAST denial, so `not(or(can('order:internal'), deny('read-only mode')))`
|
|
82
|
+
**allowed an anonymous caller** — `deny`'s `X_FORBIDDEN` had overwritten the code `not()` had to
|
|
83
|
+
recognise.
|
|
84
|
+
|
|
85
|
+
`and()` and `or()` refuse an empty clause list at the call that builds them. Nobody writes `and()`;
|
|
86
|
+
they write `and(...requiredCaps.map(can))` over a config-driven list that filters to nothing — and
|
|
87
|
+
an empty `and()` found nothing to deny and **allowed everyone, anonymous callers included**, on all
|
|
88
|
+
four surfaces, with no diagnostic beyond a label reading `and()`. `allow('public')` and
|
|
89
|
+
`deny('<reason>')` are the explicit spellings, so the refusal costs you nothing you cannot say
|
|
90
|
+
another way.
|
|
81
91
|
|
|
82
92
|
`policy.permissions` (or `policyPermissions(policy)`) is the flattened, deduped, sorted list of
|
|
83
93
|
every permission a tree references, `not()` clauses included. It is what a compliance report has
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/policy",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.0.0",
|
|
4
4
|
"description": "The one authz rule, evaluated identically in every surface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "14.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/errors.ts
CHANGED
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
// an action with no policy never compiles. The code and its factory stay published for a
|
|
4
4
|
// declaration site that cannot express the requirement in a type — a config-driven route table,
|
|
5
5
|
// a policy resolved by name — and `policyMissing()` is how such a site says it.
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type ErrorRetry,
|
|
8
|
+
nearestName,
|
|
9
|
+
registerErrorCodes,
|
|
10
|
+
registerErrorRetry,
|
|
11
|
+
renderCauseValue,
|
|
12
|
+
UltimateError,
|
|
13
|
+
} from '@ultimat3/core';
|
|
7
14
|
|
|
8
15
|
export const POLICY_ERROR_CODES = [
|
|
9
16
|
'X_FORBIDDEN',
|
|
10
17
|
'X_POLICY_MISSING',
|
|
11
18
|
'X_PERMISSION_UNKNOWN',
|
|
19
|
+
'X_POLICY_CLAUSE_EMPTY',
|
|
12
20
|
'X_POLICY_SURFACE_UNKNOWN',
|
|
13
21
|
'X_ROLE_REDEFINED',
|
|
14
22
|
] as const;
|
|
@@ -19,6 +27,7 @@ export const POLICY_ERROR_TITLES: Readonly<Record<PolicyErrorCode, string>> = {
|
|
|
19
27
|
X_FORBIDDEN: 'policy denied this actor',
|
|
20
28
|
X_POLICY_MISSING: 'an action was declared without a policy',
|
|
21
29
|
X_PERMISSION_UNKNOWN: 'permission string is not in the permission set',
|
|
30
|
+
X_POLICY_CLAUSE_EMPTY: 'a policy combinator was built with no clauses',
|
|
22
31
|
X_POLICY_SURFACE_UNKNOWN: 'enforce() was handed a surface no adapter answers to',
|
|
23
32
|
X_ROLE_REDEFINED: 'two modules define the same role differently',
|
|
24
33
|
};
|
|
@@ -31,6 +40,38 @@ registerErrorCodes(
|
|
|
31
40
|
Object.fromEntries(Object.entries(POLICY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
32
41
|
);
|
|
33
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Every one of them, LISTED rather than left to the `terminal` default — the call
|
|
45
|
+
* `@ultimat3/jobs`' webhook block already makes, and for its reason: `classifyThrown` reads an
|
|
46
|
+
* unregistered code carrying `terminal` as UNCLASSIFIED, so the attempt count governs and a job
|
|
47
|
+
* spends its whole retry policy re-proving an answer no attempt can change.
|
|
48
|
+
*
|
|
49
|
+
* `X_FORBIDDEN` is the one that matters, and core's own `ErrorRetry` doc comment names its case
|
|
50
|
+
* verbatim — *"the same call will fail the same way forever (a config fault, a validation error, a
|
|
51
|
+
* permission denial)"*. The same actor, the same input and the same row decide the same way on
|
|
52
|
+
* attempt five; the four declaration faults beside it cannot change between attempts at all,
|
|
53
|
+
* because each is raised while a module is still evaluating.
|
|
54
|
+
*
|
|
55
|
+
* Registered here rather than by the surfaces that THROW `X_FORBIDDEN` (http, auth, ai, realtime)
|
|
56
|
+
* for the reason the title is: this package owns the code, and a second package registering a
|
|
57
|
+
* different classification for it is a conflict `registerErrorRetry` raises rather than absorbs.
|
|
58
|
+
*
|
|
59
|
+
* Written out key by key over a CLOSED `Record<PolicyErrorCode, …>` rather than derived from
|
|
60
|
+
* `POLICY_ERROR_CODES` with a `.map`: a derivation classifies a new code by accident, and the one
|
|
61
|
+
* question worth asking of a code is whether trying it again could ever answer differently. A code
|
|
62
|
+
* added above with no row here is a missing-key TYPE error, which is the enforcement.
|
|
63
|
+
*/
|
|
64
|
+
const POLICY_ERROR_RETRY = Object.freeze<Record<PolicyErrorCode, ErrorRetry>>({
|
|
65
|
+
X_FORBIDDEN: 'terminal',
|
|
66
|
+
X_POLICY_MISSING: 'terminal',
|
|
67
|
+
X_PERMISSION_UNKNOWN: 'terminal',
|
|
68
|
+
X_POLICY_CLAUSE_EMPTY: 'terminal',
|
|
69
|
+
X_POLICY_SURFACE_UNKNOWN: 'terminal',
|
|
70
|
+
X_ROLE_REDEFINED: 'terminal',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
registerErrorRetry(POLICY_ERROR_RETRY);
|
|
74
|
+
|
|
34
75
|
export class PolicyError extends UltimateError {
|
|
35
76
|
override readonly name = 'PolicyError';
|
|
36
77
|
|
|
@@ -107,6 +148,38 @@ export const permissionUnknown = (permission: string, known: readonly string[]):
|
|
|
107
148
|
});
|
|
108
149
|
};
|
|
109
150
|
|
|
151
|
+
/**
|
|
152
|
+
* A combinator built with no clauses, refused where it is WRITTEN — the call
|
|
153
|
+
* `@ultimat3/scraping`'s `allowHosts: []` and `discriminated-union.ts`'s unroutable member both
|
|
154
|
+
* already make: a declaration that is wrong for every input is wrong at its first import, not at
|
|
155
|
+
* the request that discovers it.
|
|
156
|
+
*
|
|
157
|
+
* `and()` is the dangerous half and the reason this code exists. With no clauses its loop finds
|
|
158
|
+
* nothing to deny and answers ALLOWED, so `and(...requiredCaps.map(can))` over a list that
|
|
159
|
+
* filtered to empty is a policy admitting an ANONYMOUS caller on all four surfaces — and
|
|
160
|
+
* `meta.auth` derives from `admitsAnonymous`, so `@ultimat3/http` does not 401 first either. There
|
|
161
|
+
* was no diagnostic to follow: the label renders as `and()`.
|
|
162
|
+
*
|
|
163
|
+
* `or()` is refused for symmetry and for axiom 1, not for safety: it denies, which fails closed,
|
|
164
|
+
* but it denies with "no clause allowed this actor" — a reason naming no clause, which is a denial
|
|
165
|
+
* nobody can debug. `deny('<reason>')` is the spelling that says the same thing WITH one.
|
|
166
|
+
*
|
|
167
|
+
* Both fixes name the explicit spelling, so refusing costs a caller nothing they cannot say
|
|
168
|
+
* another way.
|
|
169
|
+
*/
|
|
170
|
+
export const emptyClauseList = (combinator: 'and' | 'or'): PolicyError =>
|
|
171
|
+
new PolicyError({
|
|
172
|
+
code: 'X_POLICY_CLAUSE_EMPTY',
|
|
173
|
+
cause:
|
|
174
|
+
combinator === 'and'
|
|
175
|
+
? 'and() was built with no clauses, so it allows every actor — anonymous callers included — on every surface'
|
|
176
|
+
: 'or() was built with no clauses, so it denies every actor and names no clause that could have allowed one',
|
|
177
|
+
fix:
|
|
178
|
+
combinator === 'and'
|
|
179
|
+
? "write allow('public') if every caller may act — otherwise pass the clauses the list was meant to hold: and(can('<resource>:<verb>'), …)"
|
|
180
|
+
: "write deny('<why nobody may act>') if nobody may act — otherwise pass the clauses the list was meant to hold: or(can('<resource>:<verb>'), …)",
|
|
181
|
+
});
|
|
182
|
+
|
|
110
183
|
/**
|
|
111
184
|
* `enforce()` was handed a surface with no adapter. Thrown rather than denied, because the value
|
|
112
185
|
* this reports on is one an index would have resolved to something: every object literal inherits
|
package/src/index.ts
CHANGED
package/src/policy-anonymous.ts
CHANGED
|
@@ -58,14 +58,20 @@ const OUTCOME_BY_KIND = Object.freeze<Record<PolicyKind, (policy: PolicyTree) =>
|
|
|
58
58
|
}
|
|
59
59
|
return 'allowed';
|
|
60
60
|
},
|
|
61
|
-
// First allowance wins; if none allow, `or` reports the LAST denial
|
|
61
|
+
// First allowance wins; if none allow, `or` reports the LAST denial — except that a clause
|
|
62
|
+
// denying for want of an actor outranks one that does not, which is `or`'s own rule in
|
|
63
|
+
// `policy.ts` and the reason `not()` can recognise an `X_UNAUTHENTICATED` raised deeper than
|
|
64
|
+
// its direct child. Both halves were wrong here identically, so the agreement test between
|
|
65
|
+
// this walk and `policy.run` stayed green over the shape neither got right.
|
|
62
66
|
or: (policy) => {
|
|
63
67
|
let last: AnonymousOutcome = 'denied';
|
|
68
|
+
let unauthenticated = false;
|
|
64
69
|
for (const child of policy.children) {
|
|
65
70
|
last = anonymousOutcome(child);
|
|
66
71
|
if (last === 'allowed') return 'allowed';
|
|
72
|
+
if (last === 'unauthenticated') unauthenticated = true;
|
|
67
73
|
}
|
|
68
|
-
return last;
|
|
74
|
+
return unauthenticated ? 'unauthenticated' : last;
|
|
69
75
|
},
|
|
70
76
|
not: (policy) => {
|
|
71
77
|
const inner = policy.children[0];
|
package/src/policy.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
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 { emptyClauseList } from './errors';
|
|
5
6
|
import { actorHas } from './grant-index';
|
|
6
7
|
import { assertPermission, type KnownPermission, type Permission } from './permissions';
|
|
7
8
|
import type { Actor } from './roles';
|
|
@@ -175,9 +176,18 @@ const combined = <I, R>(
|
|
|
175
176
|
},
|
|
176
177
|
});
|
|
177
178
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
/**
|
|
180
|
+
* First denial wins, and its reason is the reason — short-circuit, left to right.
|
|
181
|
+
*
|
|
182
|
+
* An EMPTY clause list is refused here rather than answered: the loop below finds nothing to deny
|
|
183
|
+
* and returns ALLOWED, so `and(...requiredCaps.map(can))` over a list that filtered to nothing is
|
|
184
|
+
* a policy that admits an anonymous caller on every surface. `allow('public')` is how that is said
|
|
185
|
+
* on purpose. The rest parameter stays a rest parameter — a required first argument would make the
|
|
186
|
+
* spread that produces this bug a type error AND make every legitimate spread one too.
|
|
187
|
+
*/
|
|
188
|
+
export const and = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy<I, R> => {
|
|
189
|
+
if (policies.length === 0) throw emptyClauseList('and');
|
|
190
|
+
return combined(
|
|
181
191
|
'and',
|
|
182
192
|
`and(${policies.map((policy) => policy.label).join(', ')})`,
|
|
183
193
|
policies,
|
|
@@ -189,23 +199,44 @@ export const and = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Polic
|
|
|
189
199
|
return ALLOWED;
|
|
190
200
|
},
|
|
191
201
|
);
|
|
202
|
+
};
|
|
192
203
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
204
|
+
/**
|
|
205
|
+
* First allowance wins; if none allow, the LAST denial is reported — **except** that a denial
|
|
206
|
+
* carrying `X_UNAUTHENTICATED` outranks one that does not.
|
|
207
|
+
*
|
|
208
|
+
* That exception is what makes `not()`'s rule below true of a TREE rather than only of a direct
|
|
209
|
+
* `can()` child. `not(or(can('order:internal'), deny('read-only mode')))` allowed `actor: null`:
|
|
210
|
+
* the `can` clause raised `X_UNAUTHENTICATED`, `deny`'s `X_FORBIDDEN` overwrote it as the last
|
|
211
|
+
* denial, and `not()` had nothing left to recognise, so it inverted a decision that was made
|
|
212
|
+
* because there was no actor. It is also the more accurate code on its own terms — a clause that
|
|
213
|
+
* could not be decided without an actor makes "authenticate and retry" a real instruction, which
|
|
214
|
+
* a 403 is not.
|
|
215
|
+
*
|
|
216
|
+
* `and` needs no such rule: it short-circuits, so the denial it reports IS the deciding one and
|
|
217
|
+
* every clause after it went unevaluated.
|
|
218
|
+
*
|
|
219
|
+
* An empty clause list is refused for the reason `and`'s is — see `emptyClauseList`.
|
|
220
|
+
*/
|
|
221
|
+
export const or = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy<I, R> => {
|
|
222
|
+
if (policies.length === 0) throw emptyClauseList('or');
|
|
223
|
+
return combined(
|
|
196
224
|
'or',
|
|
197
225
|
`or(${policies.map((policy) => policy.label).join(', ')})`,
|
|
198
226
|
policies,
|
|
199
227
|
(args, recorder, depth) => {
|
|
200
228
|
let last: PolicyDecision = denied('no clause allowed this actor');
|
|
229
|
+
let unauthenticated: PolicyDecision | undefined;
|
|
201
230
|
for (const policy of policies) {
|
|
202
231
|
const decision = policy.run(args, recorder, depth);
|
|
203
232
|
if (decision.allowed) return ALLOWED;
|
|
233
|
+
if (decision.code === 'X_UNAUTHENTICATED') unauthenticated = decision;
|
|
204
234
|
last = decision;
|
|
205
235
|
}
|
|
206
|
-
return last;
|
|
236
|
+
return unauthenticated ?? last;
|
|
207
237
|
},
|
|
208
238
|
);
|
|
239
|
+
};
|
|
209
240
|
|
|
210
241
|
/**
|
|
211
242
|
* Inverts a decision about an actor's grants — and only that.
|