@ultimat3/policy 23.0.0 → 25.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 +17 -1
- package/README.md +17 -1
- package/package.json +3 -3
- package/src/declaration-site.ts +51 -0
- package/src/define.ts +7 -5
- package/src/errors.ts +42 -1
- package/src/index.ts +18 -2
- package/src/permissions.ts +44 -4
- package/src/policy.ts +34 -5
- package/src/roles.ts +3 -15
- package/src/surfaces.ts +25 -9
- package/src/test-kit.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -62,6 +62,13 @@ two differ, and it is why a surface that decides on input alone needs no edit.
|
|
|
62
62
|
say it in a type (a config-driven route table, a policy resolved by name). Never default
|
|
63
63
|
to allow.
|
|
64
64
|
- `can()` validates its permission at declaration time, not at request time.
|
|
65
|
+
- **Only `true` or a real decision allows** (`isDecision`, `policy.ts`). A predicate or a
|
|
66
|
+
`definePolicy` `check` returning anything else — `undefined`, `1`, `{ allowed: 'yes' }` — DENIES;
|
|
67
|
+
it used to reach `decision.allowed` as a bare `TypeError`, and a truthy `allowed` read as ALLOWED.
|
|
68
|
+
- **A denial keeps its code everywhere.** `assertAllowed` throws `codeOf(decision)` (`denialError`:
|
|
69
|
+
`X_FORBIDDEN` through `policyForbidden()`, any other code as a `PolicyDenialError`) and
|
|
70
|
+
`HttpDenial.status` follows it — 401 for `X_UNAUTHENTICATED`, 403 otherwise. `X_UNAUTHENTICATED`
|
|
71
|
+
is `@ultimat3/auth`'s (`POLICY_BORROWED_ERROR_CODES`): named here, titled there.
|
|
65
72
|
- **`definePermissions()` merges, and an EMPTY registry is permissive.** It only ever `add`s, so a
|
|
66
73
|
package that declares its own names at import time (`@ultimat3/admin`'s policy bridge) cannot
|
|
67
74
|
clobber an app's set. What it does change is the mode: `isKnownPermission` answers `true` to
|
|
@@ -73,6 +80,14 @@ two differ, and it is why a surface that decides on input alone needs no edit.
|
|
|
73
80
|
process — so a clear in one test file is permanent for every file after it, whose own `import` is a
|
|
74
81
|
cache hit that declares nothing. `restoreRoles` takes the declaration sites too: `defineRoles()` derives
|
|
75
82
|
them from the CALLER's stack, so restoring through it would make `X_ROLE_REDEFINED` name the harness.
|
|
83
|
+
- **Every permission records WHERE it was declared** (`permissionDeclarationSites()`, `As of 2026-10-02`):
|
|
84
|
+
one stack frame per distinct `definePermissions()` call, through `declaration-site.ts`, the helper
|
|
85
|
+
`defineRoles()` uses. All sites, not the first — the question asked of it is provenance:
|
|
86
|
+
`@ultimat3/cli`'s `policy` step flags an app rule whose permission only a package declared
|
|
87
|
+
(`defineAdmin()`'s `<entity>:read`), `X_PERMISSION_BORROWED`. `restorePermissions(names, sites)`
|
|
88
|
+
takes them back for the same reason `restoreRoles` does, and the second argument is REQUIRED — a
|
|
89
|
+
name restored with none has no provable origin and is not judged, so a default would have let a
|
|
90
|
+
forgotten argument switch the check off.
|
|
76
91
|
- **`and()` and `or()` REFUSE an empty clause list** (`X_POLICY_CLAUSE_EMPTY`, `As of 2026-08-25`),
|
|
77
92
|
at the call that builds them. An empty `and()` found nothing to deny and answered ALLOWED, so
|
|
78
93
|
`and(...requiredCaps.map(can))` over a list that filtered to nothing — a config-driven or
|
|
@@ -160,8 +175,9 @@ reappearing there is a failing test.
|
|
|
160
175
|
| `decisions.ts` | the `DecisionSink` seam — no-op default, one call site, never PII |
|
|
161
176
|
| `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
|
|
162
177
|
| `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
|
|
178
|
+
| `declaration-site.ts` | the caller's stack frame a role or permission declaration records |
|
|
163
179
|
| `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
|
|
164
|
-
| `test-kit.ts` | `policyMatrix()` for generated policy tests, and `testActor()` — whose `.actor` is typed `Actor`, never `null`, so it goes straight into `
|
|
180
|
+
| `test-kit.ts` | `policyMatrix()` for generated policy tests, and `testActor()` — whose `.actor` is typed `Actor`, never `null`, so it goes straight into `ctxOf({ actor })` |
|
|
165
181
|
|
|
166
182
|
## The hot path
|
|
167
183
|
|
package/README.md
CHANGED
|
@@ -121,13 +121,24 @@ error shape; allowed returns `undefined`.
|
|
|
121
121
|
|
|
122
122
|
| Adapter | Denial shape |
|
|
123
123
|
|---|---|
|
|
124
|
-
| `enforceHttp` | `403`
|
|
124
|
+
| `enforceHttp` | RFC-9457 fields, and the status its code means: `401` for `X_UNAUTHENTICATED` (no actor), `403` for every other denial. The title is the code's registered one |
|
|
125
125
|
| `enforceLive` | close frame `4403` |
|
|
126
126
|
| `enforceJob` | `failed`, `retryable: false` — the answer will not change on retry |
|
|
127
127
|
| `enforceMcp` | `isError: true` with readable text |
|
|
128
128
|
|
|
129
129
|
Adding a fifth surface means adding an adapter here **and nothing else**.
|
|
130
130
|
|
|
131
|
+
`assertAllowed(policy, args)` is the throwing form, and it throws the code the DECISION carries:
|
|
132
|
+
`X_UNAUTHENTICATED` for no actor, `X_FORBIDDEN` for an actor who may not, and the code a predicate
|
|
133
|
+
chose with `denied(reason, code)` otherwise (`denialError`, a `PolicyDenialError` for a code this
|
|
134
|
+
package does not own).
|
|
135
|
+
|
|
136
|
+
A predicate — `can(p, fn)` or `definePolicy`'s `check` — allows only by returning `true` or an
|
|
137
|
+
`{ allowed: true }` decision. `false` denies, and so does anything that is neither a boolean nor a
|
|
138
|
+
decision (`undefined` from a forgotten `return`, `1`, `{ allowed: 'yes' }`): with `check` under
|
|
139
|
+
the declared `deny` key, with `can()` as `<permission> predicate returned neither a boolean nor a
|
|
140
|
+
decision`. Never a bare `TypeError`, and never an allow.
|
|
141
|
+
|
|
131
142
|
`enforce(surface, policy, args)` dispatches over that table with `Object.hasOwn`, and a surface
|
|
132
143
|
with no adapter is `X_POLICY_SURFACE_UNKNOWN`. Not a formality: the table is an object literal, so
|
|
133
144
|
it inherits `Object.prototype` — `enforce('valueOf' as Surface, …)` used to call
|
|
@@ -147,6 +158,11 @@ adds roles; it never deletes the first module's. A role two modules define *diff
|
|
|
147
158
|
`X_ROLE_REDEFINED`, naming both declaration sites — and an identical re-declaration is a no-op,
|
|
148
159
|
so `defineRoles({ ...roleDefinitions(), … })` stays legal.
|
|
149
160
|
|
|
161
|
+
`permissionDeclarationSites()` answers where each permission was declared — one stack frame per
|
|
162
|
+
distinct `definePermissions()` call. `x verify`'s `policy` step reads it to refuse an app rule
|
|
163
|
+
whose permission only a package declared (`X_PERMISSION_BORROWED`: `defineAdmin()` declares
|
|
164
|
+
`<entity>:read|write|delete`, and a process that never mounts the admin does not).
|
|
165
|
+
|
|
150
166
|
The flattened grant set is memoised per actor and invalidated the moment the role map changes.
|
|
151
167
|
It is keyed on the actor object, so it lives exactly as long as the request does: `@ultimat3/auth`
|
|
152
168
|
re-reads the user row every request, and a revoked role takes effect on the next one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/policy",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "The one authz rule, evaluated identically in every surface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
35
|
+
"@ultimat3/core": "25.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Single responsibility: the stack frame of whoever called a declaration — `defineRoles()` or
|
|
2
|
+
// `definePermissions()` — which is the only place the declaring module's name exists by then.
|
|
3
|
+
|
|
4
|
+
export const UNKNOWN_SITE = 'unknown site';
|
|
5
|
+
|
|
6
|
+
/** `\` → `/`: Windows frames and `import.meta.dir` use `\`, and the match must hold on both. */
|
|
7
|
+
const toPosix = (text: string): string => text.replaceAll('\\', '/');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* This package's own source directory — `src/` in the workspace, `dist/` or `node_modules/…` once
|
|
11
|
+
* installed. A frame is internal by the FILE it runs in (one of the three below), never by a name it spells: matching
|
|
12
|
+
* `/definePermissions/` against the whole line skipped an app helper named
|
|
13
|
+
* `definePermissionsForPosts`, and the site became whichever frame came next — a layout, or a
|
|
14
|
+
* module outside the app root that the `policy` step then read as a package's declaration.
|
|
15
|
+
*/
|
|
16
|
+
const OWN_DIR = import.meta.dir;
|
|
17
|
+
|
|
18
|
+
/** The three implementation files a declaration passes through — a test beside them is a caller. */
|
|
19
|
+
const INTERNAL_NAMES = ['declaration-site', 'permissions', 'roles'] as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The first frame of `stack` that runs outside `ownDir`'s three implementation files. Both sides
|
|
23
|
+
* are compared `/`-normalised — on Windows neither the frame nor `import.meta.dir` holds a `/`,
|
|
24
|
+
* so a raw `${dir}/` prefix matched nothing and every site was `callerSite` itself. The frame is
|
|
25
|
+
* returned as written: its readers (`frameFile`) take a native path.
|
|
26
|
+
*/
|
|
27
|
+
export const pickCallerSite = (stack: string, ownDir: string): string => {
|
|
28
|
+
const dir = toPosix(ownDir).replace(/\/$/, '');
|
|
29
|
+
const prefixes = INTERNAL_NAMES.map((name) => `${dir}/${name}.`);
|
|
30
|
+
const isInternal = (frame: string): boolean => {
|
|
31
|
+
const posix = toPosix(frame);
|
|
32
|
+
return prefixes.some((prefix) => {
|
|
33
|
+
const at = posix.indexOf(prefix);
|
|
34
|
+
// `permissions.test.ts` shares the prefix; only `<name>.<ext>:<line>` is the file itself.
|
|
35
|
+
return at !== -1 && /^[cm]?[jt]sx?:\d/.test(posix.slice(at + prefix.length));
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const frames = stack.split('\n').slice(1);
|
|
39
|
+
const chosen = frames.find((line) => !isInternal(line)) ?? frames[0] ?? '';
|
|
40
|
+
return chosen.trim() === '' ? UNKNOWN_SITE : chosen.trim();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The first frame outside this package. Not a throw: an `Error` is built only to read its stack,
|
|
45
|
+
* because `X_ROLE_REDEFINED` is unactionable without both sides of a collision and the policy step
|
|
46
|
+
* cannot say which module declared a permission without it.
|
|
47
|
+
*/
|
|
48
|
+
export const callerSite = (): string => {
|
|
49
|
+
const stack = new Error().stack;
|
|
50
|
+
return stack === undefined ? UNKNOWN_SITE : pickCallerSite(stack, OWN_DIR);
|
|
51
|
+
};
|
package/src/define.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { KnownPermission } from './permissions';
|
|
17
17
|
import type { Policy, PolicyArgs, PolicyDecision } from './policy';
|
|
18
|
-
import { can, denied } from './policy';
|
|
18
|
+
import { can, denied, isDecision } from './policy';
|
|
19
19
|
|
|
20
20
|
export interface DefinePolicyInput<I, R = unknown> {
|
|
21
21
|
/**
|
|
@@ -25,7 +25,8 @@ export interface DefinePolicyInput<I, R = unknown> {
|
|
|
25
25
|
readonly deny: string;
|
|
26
26
|
/**
|
|
27
27
|
* Pure decision over the actor, the already-loaded input and the already-loaded row.
|
|
28
|
-
* Returning `false`
|
|
28
|
+
* Returning `false` — or anything that is neither `true` nor a `PolicyDecision` — denies with
|
|
29
|
+
* `deny`; return a `PolicyDecision` for a more specific reason.
|
|
29
30
|
*
|
|
30
31
|
* Must not perform I/O — see the file header. A rule that needs a row reads `args.row`,
|
|
31
32
|
* which the surface loaded and passed in; `row` is `null` when the rule decides on input
|
|
@@ -63,9 +64,10 @@ export const definePolicy = <I = unknown, R = unknown>(
|
|
|
63
64
|
const { deny, check } = input;
|
|
64
65
|
if (check === undefined) return can<I, R>(permission);
|
|
65
66
|
return can<I, R>(permission, (args) => {
|
|
66
|
-
|
|
67
|
+
// `unknown`, whatever the type says: only `true` allows and only a real decision speaks for
|
|
68
|
+
// itself. `undefined` from a forgotten `return` — and anything else — denies with `deny`.
|
|
69
|
+
const outcome: unknown = check(args);
|
|
67
70
|
if (outcome === true) return true;
|
|
68
|
-
|
|
69
|
-
return outcome;
|
|
71
|
+
return isDecision(outcome) ? outcome : denied(deny);
|
|
70
72
|
});
|
|
71
73
|
};
|
package/src/errors.ts
CHANGED
|
@@ -98,7 +98,7 @@ export class PolicyError extends UltimateError {
|
|
|
98
98
|
const BARE_PERMISSION = /^[a-z0-9_-]+:[a-z0-9_-]+$/;
|
|
99
99
|
|
|
100
100
|
/** `reason` comes from a decision and is always safe to log: no row data, no PII. */
|
|
101
|
-
export const
|
|
101
|
+
export const policyForbidden = (label: string, reason: string): PolicyError =>
|
|
102
102
|
new PolicyError({
|
|
103
103
|
code: 'X_FORBIDDEN',
|
|
104
104
|
cause: `${label} denied: ${reason}`,
|
|
@@ -111,6 +111,47 @@ export const forbidden = (label: string, reason: string): PolicyError =>
|
|
|
111
111
|
callerFix: deniedCallerFix(BARE_PERMISSION.test(label) ? label : undefined),
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The codes a DENIAL may carry that this package does not own. `X_UNAUTHENTICATED` is
|
|
116
|
+
* `@ultimat3/auth`'s — a sibling tier, so it is named here and titled there — and it is the one
|
|
117
|
+
* `can()` itself decides with, for a null actor.
|
|
118
|
+
*/
|
|
119
|
+
export const POLICY_BORROWED_ERROR_CODES = ['X_UNAUTHENTICATED'] as const;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A denial thrown under the code its DECISION carries, when that code is not `X_FORBIDDEN`: the
|
|
123
|
+
* borrowed `X_UNAUTHENTICATED`, or one an app's own predicate chose with `denied(reason, code)`.
|
|
124
|
+
* Not a `PolicyError` — that class is closed over the codes this package owns.
|
|
125
|
+
*/
|
|
126
|
+
export class PolicyDenialError extends UltimateError {
|
|
127
|
+
override readonly name = 'PolicyDenialError';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The error a denial is thrown as: `X_FORBIDDEN` through `policyForbidden()`, anything else under its
|
|
132
|
+
* own code. `assertAllowed` threw `X_FORBIDDEN` for every denial, so an anonymous caller read
|
|
133
|
+
* "denied" where every adapter reports `X_UNAUTHENTICATED` — the code a 401 and a sign-in
|
|
134
|
+
* redirect key on.
|
|
135
|
+
*/
|
|
136
|
+
export const denialError = (label: string, reason: string, code: string): UltimateError => {
|
|
137
|
+
if (code === 'X_FORBIDDEN') return policyForbidden(label, reason);
|
|
138
|
+
const subject = BARE_PERMISSION.test(label)
|
|
139
|
+
? `x policy explain ${label} --json`
|
|
140
|
+
: 'x policy list --json';
|
|
141
|
+
return new PolicyDenialError({
|
|
142
|
+
code,
|
|
143
|
+
cause: `${label} denied: ${reason}`,
|
|
144
|
+
fix:
|
|
145
|
+
code === 'X_UNAUTHENTICATED'
|
|
146
|
+
? `${subject} # the rule needs an actor: evaluate it with the request's actor, after the caller has signed in`
|
|
147
|
+
: `${subject} # shows which clause decided and why`,
|
|
148
|
+
callerFix:
|
|
149
|
+
code === 'X_UNAUTHENTICATED'
|
|
150
|
+
? 'sign in, or send a session cookie or an Authorization header, then call again'
|
|
151
|
+
: deniedCallerFix(BARE_PERMISSION.test(label) ? label : undefined),
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
114
155
|
export const policyMissing = (subject: string): PolicyError =>
|
|
115
156
|
new PolicyError({
|
|
116
157
|
code: 'X_POLICY_MISSING',
|
package/src/index.ts
CHANGED
|
@@ -11,12 +11,15 @@ export type { DefinePolicyInput } from './define';
|
|
|
11
11
|
export { definePolicy } from './define';
|
|
12
12
|
export type { PolicyErrorCode } from './errors';
|
|
13
13
|
export {
|
|
14
|
+
denialError,
|
|
14
15
|
emptyClauseList,
|
|
15
|
-
|
|
16
|
+
POLICY_BORROWED_ERROR_CODES,
|
|
16
17
|
POLICY_ERROR_CODES,
|
|
17
18
|
POLICY_ERROR_TITLES,
|
|
19
|
+
PolicyDenialError,
|
|
18
20
|
PolicyError,
|
|
19
21
|
permissionUnknown,
|
|
22
|
+
policyForbidden,
|
|
20
23
|
policyMissing,
|
|
21
24
|
roleRedefined,
|
|
22
25
|
} from './errors';
|
|
@@ -42,6 +45,7 @@ export {
|
|
|
42
45
|
definePermissions,
|
|
43
46
|
isKnownPermission,
|
|
44
47
|
knownPermissions,
|
|
48
|
+
permissionDeclarationSites,
|
|
45
49
|
resourceOf,
|
|
46
50
|
restorePermissions,
|
|
47
51
|
verbOf,
|
|
@@ -55,7 +59,18 @@ export type {
|
|
|
55
59
|
Recorder,
|
|
56
60
|
TraceEntry,
|
|
57
61
|
} from './policy';
|
|
58
|
-
export {
|
|
62
|
+
export {
|
|
63
|
+
ALLOWED,
|
|
64
|
+
allow,
|
|
65
|
+
and,
|
|
66
|
+
can,
|
|
67
|
+
denied,
|
|
68
|
+
deny,
|
|
69
|
+
not,
|
|
70
|
+
or,
|
|
71
|
+
policyCapability,
|
|
72
|
+
policyPermissions,
|
|
73
|
+
} from './policy';
|
|
59
74
|
export { admitsAnonymous } from './policy-anonymous';
|
|
60
75
|
export type { PreInputArgs } from './pre-input';
|
|
61
76
|
export { decideBeforeInput, enforceBeforeInput } from './pre-input';
|
|
@@ -72,6 +87,7 @@ export {
|
|
|
72
87
|
rolesGranting,
|
|
73
88
|
} from './roles';
|
|
74
89
|
export type {
|
|
90
|
+
DenialStatus,
|
|
75
91
|
HttpDenial,
|
|
76
92
|
JobDenial,
|
|
77
93
|
LiveDenial,
|
package/src/permissions.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Permissions are `resource:verb` strings. Two layers of protection: the template
|
|
2
2
|
// literal type rejects a malformed string at compile time, and an app that augments
|
|
3
3
|
// `PermissionRegistry` (which `x g policy` does) turns a typo into a type error.
|
|
4
|
+
import { callerSite } from './declaration-site';
|
|
4
5
|
import { permissionUnknown } from './errors';
|
|
5
6
|
|
|
6
7
|
export type Permission = `${string}:${string}`;
|
|
@@ -36,9 +37,27 @@ export interface PermissionSet<P extends Permission> {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const declared = new Set<string>();
|
|
40
|
+
/** Every distinct module that declared each name — a `Map`, because a name is caller data. */
|
|
41
|
+
const sites = new Map<string, Set<string>>();
|
|
39
42
|
|
|
40
43
|
export const knownPermissions = (): readonly string[] => [...declared].sort();
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Where each permission was declared: one stack frame per distinct declaring call, as
|
|
47
|
+
* `roleDeclarationSites()` answers for roles. ALL of them rather than the first, because the
|
|
48
|
+
* question asked of it is provenance — the `policy` step flags an app rule that holds only because
|
|
49
|
+
* a package it imports (`@ultimat3/admin`'s `defineAdmin()`) declared the name, and an app that
|
|
50
|
+
* declares the same name itself is then not leaning on anything.
|
|
51
|
+
*/
|
|
52
|
+
export const permissionDeclarationSites = (): Readonly<Record<string, readonly string[]>> =>
|
|
53
|
+
Object.fromEntries([...sites].map(([name, at]) => [name, [...at]]));
|
|
54
|
+
|
|
55
|
+
const recordSite = (permission: string, site: string): void => {
|
|
56
|
+
const at = sites.get(permission);
|
|
57
|
+
if (at === undefined) sites.set(permission, new Set([site]));
|
|
58
|
+
else at.add(site);
|
|
59
|
+
};
|
|
60
|
+
|
|
42
61
|
/**
|
|
43
62
|
* Runtime membership check. It stays silent until an app has declared its set,
|
|
44
63
|
* because there is nothing to check against before then — `x verify` is what fails
|
|
@@ -59,7 +78,11 @@ export const verbOf = (permission: string): string => permission.split(':')[1] ?
|
|
|
59
78
|
export const definePermissions = <const P extends readonly Permission[]>(
|
|
60
79
|
list: P,
|
|
61
80
|
): PermissionSet<P[number]> => {
|
|
62
|
-
|
|
81
|
+
const site = callerSite();
|
|
82
|
+
for (const permission of list) {
|
|
83
|
+
declared.add(permission);
|
|
84
|
+
recordSite(permission, site);
|
|
85
|
+
}
|
|
63
86
|
const all = [...list] as P[number][];
|
|
64
87
|
return {
|
|
65
88
|
all,
|
|
@@ -74,7 +97,10 @@ export const definePermissions = <const P extends readonly Permission[]>(
|
|
|
74
97
|
};
|
|
75
98
|
|
|
76
99
|
/** Test seam; production never forgets a permission it declared. */
|
|
77
|
-
export const clearPermissions = (): void =>
|
|
100
|
+
export const clearPermissions = (): void => {
|
|
101
|
+
declared.clear();
|
|
102
|
+
sites.clear();
|
|
103
|
+
};
|
|
78
104
|
|
|
79
105
|
/**
|
|
80
106
|
* `clearPermissions()`'s other half, taking exactly what `knownPermissions()` answers.
|
|
@@ -85,8 +111,22 @@ export const clearPermissions = (): void => declared.clear();
|
|
|
85
111
|
* registers nothing, and `can('admin:read')` throws X_PERMISSION_UNKNOWN for a permission the
|
|
86
112
|
* process really did declare. Only putting the captured set back repairs it; re-importing cannot.
|
|
87
113
|
* Replaces rather than merges — a capture is the whole truth about the process, not an addition.
|
|
114
|
+
*
|
|
115
|
+
* `declaredAt` is what `permissionDeclarationSites()` answered, and the same rule `restoreRoles`
|
|
116
|
+
* states: re-declaring through `definePermissions` would name THIS frame as every permission's
|
|
117
|
+
* origin. REQUIRED, never defaulted: an omitted argument restored every name with no site, and a
|
|
118
|
+
* name with no site is one the policy step cannot judge — so a forgotten argument silently turned
|
|
119
|
+
* `X_PERMISSION_BORROWED` off for the rest of the process. `{}` says "no provenance" on purpose.
|
|
88
120
|
*/
|
|
89
|
-
export const restorePermissions = (
|
|
121
|
+
export const restorePermissions = (
|
|
122
|
+
permissions: readonly string[],
|
|
123
|
+
declaredAt: Readonly<Record<string, readonly string[]>>,
|
|
124
|
+
): void => {
|
|
90
125
|
declared.clear();
|
|
91
|
-
|
|
126
|
+
sites.clear();
|
|
127
|
+
for (const permission of permissions) {
|
|
128
|
+
declared.add(permission);
|
|
129
|
+
const at = Object.hasOwn(declaredAt, permission) ? declaredAt[permission] : undefined;
|
|
130
|
+
for (const site of at ?? []) recordSite(permission, site);
|
|
131
|
+
}
|
|
92
132
|
};
|
package/src/policy.ts
CHANGED
|
@@ -84,11 +84,33 @@ const record = (
|
|
|
84
84
|
return decision;
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
/**
|
|
88
|
+
* A value that IS a decision, by its shape and nothing looser: `allowed` exactly `true`, or
|
|
89
|
+
* exactly `false` with the reason and code a denial carries. `{ allowed: 'yes' }` is not one —
|
|
90
|
+
* read as a decision it was truthy, and every surface took it for ALLOWED.
|
|
91
|
+
*/
|
|
92
|
+
export const isDecision = (value: unknown): value is PolicyDecision => {
|
|
93
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
94
|
+
const { allowed, reason, code } = value as Readonly<Record<string, unknown>>;
|
|
95
|
+
return (
|
|
96
|
+
allowed === true ||
|
|
97
|
+
(allowed === false && typeof reason === 'string' && typeof code === 'string')
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `true` allows. Everything that is not `true` and not a decision DENIES: a predicate is typed
|
|
103
|
+
* `boolean | PolicyDecision`, but a JS caller, an `as` and a forgotten `return` all hand back
|
|
104
|
+
* something else, and passing it through made `decision.allowed` a bare `TypeError` out of `run`.
|
|
105
|
+
*/
|
|
106
|
+
const asDecision = (result: unknown, label: string): PolicyDecision => {
|
|
107
|
+
if (result === true) return ALLOWED;
|
|
108
|
+
if (isDecision(result)) return result;
|
|
109
|
+
return denied(
|
|
110
|
+
result === false
|
|
111
|
+
? `${label} predicate returned false`
|
|
112
|
+
: `${label} predicate returned neither a boolean nor a decision`,
|
|
113
|
+
);
|
|
92
114
|
};
|
|
93
115
|
|
|
94
116
|
/**
|
|
@@ -175,6 +197,13 @@ export const policyPermissions = <I = unknown, R = unknown>(
|
|
|
175
197
|
policy: Policy<I, R>,
|
|
176
198
|
): readonly Permission[] => policy.permissions;
|
|
177
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The capability a policy names, for manifests, OpenAPI metadata and the `/_x` dashboard — a
|
|
202
|
+
* DISPLAY label. What a report MATCHES on is `policyPermissions`; this is the sentence beside it.
|
|
203
|
+
*/
|
|
204
|
+
export const policyCapability = <I = unknown, R = unknown>(policy: Policy<I, R>): string =>
|
|
205
|
+
policy.label;
|
|
206
|
+
|
|
178
207
|
const flatten = (children: readonly { readonly permissions: readonly Permission[] }[]) =>
|
|
179
208
|
[...new Set(children.flatMap((child) => child.permissions))].sort();
|
|
180
209
|
|
package/src/roles.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// evaluator never has to reason about hierarchy — and a cycle is caught here, once.
|
|
4
4
|
// The per-actor flattening and its memo live in `grant-index.ts`; this file owns the map.
|
|
5
5
|
import type { Actor as CoreActor } from '@ultimat3/core';
|
|
6
|
+
import { callerSite, UNKNOWN_SITE } from './declaration-site';
|
|
6
7
|
import { roleRedefined } from './errors';
|
|
7
8
|
import { resourceOf } from './permissions';
|
|
8
9
|
|
|
@@ -42,19 +43,6 @@ let generation = 0;
|
|
|
42
43
|
*/
|
|
43
44
|
export const roleMapGeneration = (): number => generation;
|
|
44
45
|
|
|
45
|
-
/** Frames inside this file — never the answer to "who declared this role?". */
|
|
46
|
-
const INTERNAL_FRAME = /declarationSite|defineRoles/;
|
|
47
|
-
|
|
48
|
-
const declarationSite = (): string => {
|
|
49
|
-
// Not a throw: the stack is the only place the declaring module's name exists at this point,
|
|
50
|
-
// and `X_ROLE_REDEFINED` is unactionable without both sides of the collision.
|
|
51
|
-
const stack = new Error().stack;
|
|
52
|
-
if (stack === undefined) return 'unknown site';
|
|
53
|
-
const frames = stack.split('\n').slice(1);
|
|
54
|
-
const frame = frames.find((line) => !INTERNAL_FRAME.test(line)) ?? frames[0] ?? '';
|
|
55
|
-
return frame.trim() === '' ? 'unknown site' : frame.trim();
|
|
56
|
-
};
|
|
57
|
-
|
|
58
46
|
/**
|
|
59
47
|
* A role name is caller data, and an app's role map is a plain object literal — so `map['constructor']`
|
|
60
48
|
* answers the `Object` FUNCTION rather than `undefined`, the `=== undefined` guard at the call site
|
|
@@ -100,13 +88,13 @@ const sameDefinition = (left: RoleDef, right: RoleDef): boolean =>
|
|
|
100
88
|
* and every request 403'd on whichever bundler ordering CI happened to pick.
|
|
101
89
|
*/
|
|
102
90
|
export const defineRoles = <const M extends RoleMap>(map: M): M => {
|
|
103
|
-
const site =
|
|
91
|
+
const site = callerSite();
|
|
104
92
|
const merged: Record<string, RoleDef> = { ...roleMap };
|
|
105
93
|
const nextSites: Record<string, string> = { ...sites };
|
|
106
94
|
for (const [role, definition] of Object.entries(map)) {
|
|
107
95
|
const existing = own(merged, role);
|
|
108
96
|
if (existing !== undefined && !sameDefinition(existing, definition)) {
|
|
109
|
-
throw roleRedefined(role, own(sites, role) ??
|
|
97
|
+
throw roleRedefined(role, own(sites, role) ?? UNKNOWN_SITE, site);
|
|
110
98
|
}
|
|
111
99
|
put(merged, role, definition);
|
|
112
100
|
put(nextSites, role, own(nextSites, role) ?? site);
|
package/src/surfaces.ts
CHANGED
|
@@ -9,19 +9,23 @@
|
|
|
9
9
|
// The shapes are declared structurally rather than imported: `@ultimat3/http` is a
|
|
10
10
|
// sibling tier, and jobs/realtime/mcp are higher tiers that import this package.
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { describeErrorCode } from '@ultimat3/core';
|
|
13
|
+
import { denialError, surfaceUnknown } from './errors';
|
|
13
14
|
import { codeOf, type EvaluateArgs, evaluate, type PolicyEvaluation, reasonOf } from './evaluate';
|
|
14
15
|
import type { Policy } from './policy';
|
|
15
16
|
|
|
16
17
|
export type Surface = 'http' | 'live' | 'job' | 'mcp';
|
|
17
18
|
|
|
19
|
+
/** 401 when the decision says nobody is signed in, 403 for every other denial. */
|
|
20
|
+
export type DenialStatus = 401 | 403;
|
|
21
|
+
|
|
18
22
|
export interface HttpDenial {
|
|
19
23
|
readonly surface: 'http';
|
|
20
|
-
readonly status:
|
|
24
|
+
readonly status: DenialStatus;
|
|
21
25
|
/** RFC-9457 fields `@ultimat3/http` renders verbatim. */
|
|
22
26
|
readonly problem: {
|
|
23
27
|
readonly title: string;
|
|
24
|
-
readonly status:
|
|
28
|
+
readonly status: DenialStatus;
|
|
25
29
|
readonly detail: string;
|
|
26
30
|
readonly code: string;
|
|
27
31
|
};
|
|
@@ -54,20 +58,32 @@ const reason = (evaluation: PolicyEvaluation): string => reasonOf(evaluation.dec
|
|
|
54
58
|
|
|
55
59
|
const code = (evaluation: PolicyEvaluation): string => codeOf(evaluation.decision) ?? 'X_FORBIDDEN';
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The status a denial's code means. Only `X_UNAUTHENTICATED` is "come back signed in"; every other
|
|
63
|
+
* code — `X_FORBIDDEN`, or one an app's predicate chose — is a refusal of this actor. It was a
|
|
64
|
+
* pinned `403` beside a problem whose own `code` said `X_UNAUTHENTICATED`.
|
|
65
|
+
*/
|
|
66
|
+
const statusOf = (denialCode: string): DenialStatus =>
|
|
67
|
+
denialCode === 'X_UNAUTHENTICATED' ? 401 : 403;
|
|
68
|
+
|
|
57
69
|
export const enforceHttp = <I, R = unknown>(
|
|
58
70
|
policy: Policy<I, R>,
|
|
59
71
|
args: EvaluateArgs<I, R>,
|
|
60
72
|
): HttpDenial | undefined => {
|
|
61
73
|
const evaluation = evaluate(policy, args, { surface: 'http' });
|
|
62
74
|
if (evaluation.allowed) return undefined;
|
|
75
|
+
const denialCode = code(evaluation);
|
|
76
|
+
const status = statusOf(denialCode);
|
|
63
77
|
return {
|
|
64
78
|
surface: 'http',
|
|
65
|
-
status
|
|
79
|
+
status,
|
|
66
80
|
problem: {
|
|
67
|
-
title:
|
|
68
|
-
|
|
81
|
+
// The CODE's registered title, so the document reads as one fact: the owner of the code
|
|
82
|
+
// wrote it once, and `X_FORBIDDEN`'s own title is this package's.
|
|
83
|
+
title: describeErrorCode(denialCode).title,
|
|
84
|
+
status,
|
|
69
85
|
detail: reason(evaluation),
|
|
70
|
-
code:
|
|
86
|
+
code: denialCode,
|
|
71
87
|
},
|
|
72
88
|
};
|
|
73
89
|
};
|
|
@@ -143,12 +159,12 @@ export const enforce = <I, R = unknown>(
|
|
|
143
159
|
return adapters[surface](policy, args);
|
|
144
160
|
};
|
|
145
161
|
|
|
146
|
-
/** For call sites that would rather throw than branch. Same decision, same reason. */
|
|
162
|
+
/** For call sites that would rather throw than branch. Same decision, same reason, same CODE. */
|
|
147
163
|
export const assertAllowed = <I, R = unknown>(
|
|
148
164
|
policy: Policy<I, R>,
|
|
149
165
|
args: EvaluateArgs<I, R>,
|
|
150
166
|
): PolicyEvaluation => {
|
|
151
167
|
const evaluation = evaluate(policy, args);
|
|
152
|
-
if (!evaluation.allowed) throw
|
|
168
|
+
if (!evaluation.allowed) throw denialError(policy.label, reason(evaluation), code(evaluation));
|
|
153
169
|
return evaluation;
|
|
154
170
|
};
|
package/src/test-kit.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const policyMatrix = <I, R = unknown>(
|
|
|
100
100
|
* arrays, exactly as the actor `@ultimat3/auth` resolves per request is.
|
|
101
101
|
*
|
|
102
102
|
* The return type says the actor is THERE. `NamedActor` keeps `null` for the matrix's signed-out
|
|
103
|
-
* row; a minted actor is never that, and while the type said it might be, `
|
|
103
|
+
* row; a minted actor is never that, and while the type said it might be, `ctxOf({ actor })`
|
|
104
104
|
* refused it — so every fixture that built a context reached for core's `userActor` instead and a
|
|
105
105
|
* generated test held two idioms for one thing.
|
|
106
106
|
*/
|