@ultimat3/policy 2.0.0 → 4.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 +42 -1
- package/package.json +2 -2
- package/src/grant-index.ts +3 -0
- package/src/index.ts +3 -1
- package/src/permissions.ts +15 -0
- package/src/roles.ts +56 -16
- package/src/test-kit.ts +41 -11
package/CLAUDE.md
CHANGED
|
@@ -45,12 +45,53 @@ two differ, and it is why a surface that decides on input alone needs no edit.
|
|
|
45
45
|
say it in a type (a config-driven route table, a policy resolved by name). Never default
|
|
46
46
|
to allow.
|
|
47
47
|
- `can()` validates its permission at declaration time, not at request time.
|
|
48
|
+
- **`definePermissions()` merges, and an EMPTY registry is permissive.** It only ever `add`s, so a
|
|
49
|
+
package that declares its own names at import time (`@ultimat3/admin`'s policy bridge) cannot
|
|
50
|
+
clobber an app's set. What it does change is the mode: `isKnownPermission` answers `true` to
|
|
51
|
+
everything while nothing is declared, so the first declaration anywhere in the process turns
|
|
52
|
+
strict checking on for everyone. A test that uses `can()` declares the set it uses and restores
|
|
53
|
+
the one it found — never leans on the empty registry.
|
|
54
|
+
- **`clearPermissions()` / `clearRoles()` are one-way; `restorePermissions()` / `restoreRoles()` are the
|
|
55
|
+
other halves.** Both declaration calls run at MODULE scope, and a module evaluates once per `bun test`
|
|
56
|
+
process — so a clear in one test file is permanent for every file after it, whose own `import` is a
|
|
57
|
+
cache hit that declares nothing. `restoreRoles` takes the declaration sites too: `defineRoles()` derives
|
|
58
|
+
them from the CALLER's stack, so restoring through it would make `X_ROLE_REDEFINED` name the harness.
|
|
48
59
|
- **`not()` never inverts `X_UNAUTHENTICATED`.** A null actor is not a fact about this
|
|
49
60
|
actor's grants; inverting it makes `not(can('order:internal'))` a public door into the
|
|
50
61
|
internal one. Any denial carrying that code propagates unchanged.
|
|
51
62
|
- **`defineRoles()` merges** and refuses a role two modules define differently
|
|
52
63
|
(`X_ROLE_REDEFINED`, naming both declaration sites). A re-declaration of an *identical*
|
|
53
64
|
role is a no-op, which is what keeps `defineRoles({ ...roleDefinitions(), … })` legal.
|
|
65
|
+
- **A role name is ACTOR data, so the role map is read with `Object.hasOwn` and written with
|
|
66
|
+
`defineProperty`** (`roles.ts`, `As of 2026-08`). An app's map is a plain object literal, so
|
|
67
|
+
`map['constructor']` answered the `Object` FUNCTION, the `definition === undefined` guard passed,
|
|
68
|
+
and `for (const grant of definition.grants)` threw a bare `TypeError` out of `evaluate` — which
|
|
69
|
+
`@ultimat3/http` re-raises to the error boundary, so an actor holding a role named `constructor`,
|
|
70
|
+
`__proto__` or `toString` turned every authz decision it made into a **500 instead of a 403**.
|
|
71
|
+
`map[name] = value` is the same hazard writing: for `__proto__` it runs `Object.prototype`'s
|
|
72
|
+
setter and files no key at all. `test-kit.ts`'s verdict map has the same two rules for the same
|
|
73
|
+
reason — `allowedFor('constructor')` answered a truthy function — and `policyMatrix` builds it
|
|
74
|
+
through `Object.fromEntries`, which defines own keys whatever they spell.
|
|
75
|
+
- **`Actor` is an ALIAS of core's, and `PolicyActorFields` is deleted** (`As of 2026-08-19`).
|
|
76
|
+
It was `CoreActor & PolicyActorFields`, a four-field interface declared here. Three fields
|
|
77
|
+
(`id`, `roles`, `orgId`) were already core's, so the intersection only restated them, and
|
|
78
|
+
`PolicyActorFields.orgId`'s `| null` never survived it — it was intersected straight back to
|
|
79
|
+
core's `string | undefined`. The fourth, `permissions`, was the only real content, and declaring
|
|
80
|
+
it here is what made it **unbuildable**: core is tier 0 and cannot import this package, so
|
|
81
|
+
`build()` had no field to carry and `userActor({ permissions })` compiled and discarded the
|
|
82
|
+
argument. Every caller worked around it with `{ ...userActor({ id }), permissions: [...] }` — a
|
|
83
|
+
spread over a frozen actor, producing an unfrozen one — so **32 fixtures across `query`,
|
|
84
|
+
`action` and a tracked app proved authz against a shape no request mints**. `@ultimat3/auth`
|
|
85
|
+
kept a second, hand-synced copy of the same interface for the same tier reason. `permissions`
|
|
86
|
+
now sits beside `roles` and `scopes` in `@ultimat3/core`.
|
|
87
|
+
- **`testActor()` goes through `userActor()`** (`As of 2026-08-19`), so it is frozen with frozen
|
|
88
|
+
arrays and a field added to `Actor` arrives here without a second list to keep in sync. It used
|
|
89
|
+
to hand-roll the literal — it had to, since core's builder had no `permissions` — and omitted
|
|
90
|
+
`kind` and `scopes` behind an `as unknown as Actor`, so `hasScope(actor, …)` threw a bare
|
|
91
|
+
`TypeError` on every actor it built and `actorLabel()` rendered `undefined:editor`: a generated
|
|
92
|
+
scope-gated policy test failed as a 500-shaped throw instead of as the denial it asserts. The one
|
|
93
|
+
cast left is `orgId: null` — core declares `orgId?: string`, so nothing typed can mint the
|
|
94
|
+
`null` that `@ultimat3/query`'s `orgless()` guards against, and that test needs a producer.
|
|
54
95
|
- No `any`. Never throw a bare `Error` — use `errors.ts`.
|
|
55
96
|
- **This package owns `X_FORBIDDEN`** and registers its title with core. `http`, `auth`
|
|
56
97
|
and every surface adapter reuse the code and must not re-register it.
|
|
@@ -76,7 +117,7 @@ reappearing there is a failing test.
|
|
|
76
117
|
| `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
|
|
77
118
|
| `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
|
|
78
119
|
| `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
|
|
79
|
-
| `test-kit.ts` | `policyMatrix()` for generated policy tests |
|
|
120
|
+
| `test-kit.ts` | `policyMatrix()` for generated policy tests, and `testActor()` |
|
|
80
121
|
|
|
81
122
|
## The hot path
|
|
82
123
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/policy",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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": "4.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/grant-index.ts
CHANGED
|
@@ -34,6 +34,9 @@ interface CacheEntry {
|
|
|
34
34
|
const cache = new WeakMap<Actor, CacheEntry>();
|
|
35
35
|
|
|
36
36
|
const buildIndex = (actor: Actor, map: RoleMap): GrantIndex => {
|
|
37
|
+
// `?? []` on both, though `Actor` declares them required and `userActor()` defaults them: this
|
|
38
|
+
// takes an `Actor | null` from surfaces that hand it a value parsed out of JSON, and an authz
|
|
39
|
+
// read that throws where it should have DENIED is the `testActor` defect one layer down.
|
|
37
40
|
const exact = new Set<string>(actor.permissions ?? []);
|
|
38
41
|
for (const grant of expandRoles(actor.roles ?? [], map)) exact.add(grant);
|
|
39
42
|
const wildcards = new Set<string>();
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export {
|
|
|
42
42
|
isKnownPermission,
|
|
43
43
|
knownPermissions,
|
|
44
44
|
resourceOf,
|
|
45
|
+
restorePermissions,
|
|
45
46
|
verbOf,
|
|
46
47
|
} from './permissions';
|
|
47
48
|
export type {
|
|
@@ -64,12 +65,13 @@ export {
|
|
|
64
65
|
or,
|
|
65
66
|
policyPermissions,
|
|
66
67
|
} from './policy';
|
|
67
|
-
export type { Actor,
|
|
68
|
+
export type { Actor, RoleDef, RoleMap } from './roles';
|
|
68
69
|
export {
|
|
69
70
|
clearRoles,
|
|
70
71
|
defineRoles,
|
|
71
72
|
expandRoles,
|
|
72
73
|
grantMatches,
|
|
74
|
+
restoreRoles,
|
|
73
75
|
roleDeclarationSites,
|
|
74
76
|
roleDefinitions,
|
|
75
77
|
roleMapGeneration,
|
package/src/permissions.ts
CHANGED
|
@@ -75,3 +75,18 @@ export const definePermissions = <const P extends readonly Permission[]>(
|
|
|
75
75
|
|
|
76
76
|
/** Test seam; production never forgets a permission it declared. */
|
|
77
77
|
export const clearPermissions = (): void => declared.clear();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `clearPermissions()`'s other half, taking exactly what `knownPermissions()` answers.
|
|
81
|
+
*
|
|
82
|
+
* `definePermissions()` runs at MODULE scope — `@ultimat3/admin` declares `admin:*` on its
|
|
83
|
+
* barrel's import — and a module evaluates once per `bun test` process. So a clear in one test
|
|
84
|
+
* file is permanent for every file after it: that file's own `import` is a cache hit which
|
|
85
|
+
* registers nothing, and `can('admin:read')` throws X_PERMISSION_UNKNOWN for a permission the
|
|
86
|
+
* process really did declare. Only putting the captured set back repairs it; re-importing cannot.
|
|
87
|
+
* Replaces rather than merges — a capture is the whole truth about the process, not an addition.
|
|
88
|
+
*/
|
|
89
|
+
export const restorePermissions = (permissions: readonly string[]): void => {
|
|
90
|
+
declared.clear();
|
|
91
|
+
for (const permission of permissions) declared.add(permission);
|
|
92
|
+
};
|
package/src/roles.ts
CHANGED
|
@@ -7,18 +7,21 @@ import { roleRedefined } from './errors';
|
|
|
7
7
|
import { resourceOf } from './permissions';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* An **alias** of core's declaration, and nothing more (`As of 2026-08-19`) — so the actor
|
|
11
|
+
* `userActor()` mints IS the actor a predicate decides about, with no spread and no second type.
|
|
12
|
+
*
|
|
13
|
+
* It used to be `CoreActor & PolicyActorFields`, a four-field interface declared here. Three of
|
|
14
|
+
* those fields (`id`, `roles`, `orgId`) were already core's, so the intersection restated them;
|
|
15
|
+
* `PolicyActorFields.orgId`'s `| null` never survived it at all, being intersected back to core's
|
|
16
|
+
* `string | undefined`. The fourth, `permissions`, was the only real content — and declaring THAT
|
|
17
|
+
* here is what made it unbuildable: core is tier 0, so `userActor({ permissions })` had no field
|
|
18
|
+
* to land in and dropped the argument in silence. It now lives beside `roles` and `scopes` in
|
|
19
|
+
* `@ultimat3/core`, where every actor is built, and this interface has nothing left to say.
|
|
20
|
+
*
|
|
21
|
+
* Same shape as `@ultimat3/entity`'s `MoneyValue`, aliased from `@ultimat3/schema` for the same
|
|
22
|
+
* reason: one declaration, re-exported by the package whose public API talks about it.
|
|
12
23
|
*/
|
|
13
|
-
export
|
|
14
|
-
readonly id: string;
|
|
15
|
-
readonly roles?: readonly string[] | undefined;
|
|
16
|
-
/** Direct grants, bypassing roles. Used by service tokens. */
|
|
17
|
-
readonly permissions?: readonly string[] | undefined;
|
|
18
|
-
readonly orgId?: string | null | undefined;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type Actor = CoreActor & PolicyActorFields;
|
|
24
|
+
export type Actor = CoreActor;
|
|
22
25
|
|
|
23
26
|
export interface RoleDef {
|
|
24
27
|
readonly grants: readonly string[];
|
|
@@ -52,6 +55,26 @@ const declarationSite = (): string => {
|
|
|
52
55
|
return frame.trim() === '' ? 'unknown site' : frame.trim();
|
|
53
56
|
};
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A role name is caller data, and an app's role map is a plain object literal — so `map['constructor']`
|
|
60
|
+
* answers the `Object` FUNCTION rather than `undefined`, the `=== undefined` guard at the call site
|
|
61
|
+
* never fires, and reading `.grants` off it throws a bare `TypeError` from inside `evaluate`. That is
|
|
62
|
+
* an authz decision arriving at the error boundary as a 500 instead of a 403, for every request an
|
|
63
|
+
* actor holding a role named `constructor`, `__proto__` or `toString` makes. Same discriminator
|
|
64
|
+
* `@ultimat3/entity`'s `tenancy.ts` uses, for the same reason.
|
|
65
|
+
*/
|
|
66
|
+
const own = <V>(map: Readonly<Record<string, V>>, name: string): V | undefined =>
|
|
67
|
+
Object.hasOwn(map, name) ? map[name] : undefined;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `map[name] = value` is the same hazard writing: for `name === '__proto__'` it runs the setter on
|
|
71
|
+
* `Object.prototype` and stores NO key, so the role would vanish between being merged and being
|
|
72
|
+
* read back. `defineProperty` files an own key whatever the name spells.
|
|
73
|
+
*/
|
|
74
|
+
const put = <V>(map: Record<string, V>, name: string, value: V): void => {
|
|
75
|
+
Object.defineProperty(map, name, { value, writable: true, enumerable: true, configurable: true });
|
|
76
|
+
};
|
|
77
|
+
|
|
55
78
|
const sameList = (left: readonly string[], right: readonly string[]): boolean => {
|
|
56
79
|
if (left.length !== right.length) return false;
|
|
57
80
|
const sortedRight = [...right].sort();
|
|
@@ -81,12 +104,12 @@ export const defineRoles = <const M extends RoleMap>(map: M): M => {
|
|
|
81
104
|
const merged: Record<string, RoleDef> = { ...roleMap };
|
|
82
105
|
const nextSites: Record<string, string> = { ...sites };
|
|
83
106
|
for (const [role, definition] of Object.entries(map)) {
|
|
84
|
-
const existing = merged
|
|
107
|
+
const existing = own(merged, role);
|
|
85
108
|
if (existing !== undefined && !sameDefinition(existing, definition)) {
|
|
86
|
-
throw roleRedefined(role, sites
|
|
109
|
+
throw roleRedefined(role, own(sites, role) ?? 'unknown site', site);
|
|
87
110
|
}
|
|
88
|
-
merged
|
|
89
|
-
nextSites
|
|
111
|
+
put(merged, role, definition);
|
|
112
|
+
put(nextSites, role, own(nextSites, role) ?? site);
|
|
90
113
|
}
|
|
91
114
|
roleMap = merged;
|
|
92
115
|
sites = nextSites;
|
|
@@ -106,6 +129,23 @@ export const clearRoles = (): void => {
|
|
|
106
129
|
generation += 1;
|
|
107
130
|
};
|
|
108
131
|
|
|
132
|
+
/**
|
|
133
|
+
* `clearRoles()`'s other half, taking what `roleDefinitions()` and `roleDeclarationSites()`
|
|
134
|
+
* answer. `defineRoles()` runs at an app's MODULE scope and a module evaluates once per
|
|
135
|
+
* `bun test` process, so a clear in one test file is permanent for every file after it — the
|
|
136
|
+
* later file's `import` is a cache hit that declares nothing.
|
|
137
|
+
*
|
|
138
|
+
* `defineRoles()` cannot be the restore: it re-derives the declaration site from the CALLER's
|
|
139
|
+
* stack, so every role would report this frame as its origin and `X_ROLE_REDEFINED` would name a
|
|
140
|
+
* site no reader can open. The generation is bumped for `grant-index.ts`, whose per-actor memo is
|
|
141
|
+
* invalidated by that number alone.
|
|
142
|
+
*/
|
|
143
|
+
export const restoreRoles = (map: RoleMap, declaredAt: Readonly<Record<string, string>>): void => {
|
|
144
|
+
roleMap = { ...map };
|
|
145
|
+
sites = { ...declaredAt };
|
|
146
|
+
generation += 1;
|
|
147
|
+
};
|
|
148
|
+
|
|
109
149
|
/**
|
|
110
150
|
* Depth-first expansion with a visited set: `owner -> admin -> editor` collapses to
|
|
111
151
|
* one list, and `a -> b -> a` terminates instead of blowing the stack.
|
|
@@ -119,7 +159,7 @@ export const expandRoles = (
|
|
|
119
159
|
const walk = (name: string): void => {
|
|
120
160
|
if (seen.has(name)) return;
|
|
121
161
|
seen.add(name);
|
|
122
|
-
const definition = map
|
|
162
|
+
const definition = own(map, name);
|
|
123
163
|
if (definition === undefined) return;
|
|
124
164
|
for (const grant of definition.grants) out.add(grant);
|
|
125
165
|
for (const parent of definition.inherits ?? []) walk(parent);
|
package/src/test-kit.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `policyMatrix()` turns "who can do this?" into a table a test can assert on in one
|
|
2
2
|
// expression. `x g policy` generates a test that calls it, so every policy ships with
|
|
3
3
|
// its allow/deny matrix and a change to a role shows up as a diff in that table.
|
|
4
|
+
import { userActor } from '@ultimat3/core';
|
|
4
5
|
import { type EvaluateArgs, evaluate, reasonOf } from './evaluate';
|
|
5
6
|
import type { Policy } from './policy';
|
|
6
7
|
import type { Actor } from './roles';
|
|
@@ -59,15 +60,21 @@ export const policyMatrix = <I, R = unknown>(
|
|
|
59
60
|
};
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
// `Object.fromEntries` rather than `verdicts[row.actor] = …`: an actor named `__proto__` assigns
|
|
64
|
+
// the PROTOTYPE through that spelling and files no key at all, so the matrix would report a
|
|
65
|
+
// verdict it never stored. Every name here is an own key.
|
|
66
|
+
const verdicts: Record<string, boolean> = Object.fromEntries(
|
|
67
|
+
rows.map((row) => [row.actor, row.allowed]),
|
|
68
|
+
);
|
|
64
69
|
|
|
65
70
|
const width = Math.max(5, ...rows.map((row) => row.actor.length));
|
|
66
71
|
return {
|
|
67
72
|
label: policy.label,
|
|
68
73
|
rows,
|
|
69
74
|
verdicts,
|
|
70
|
-
|
|
75
|
+
// `verdicts[name] ?? false` answered the `Object` FUNCTION — truthy, and not a boolean — for
|
|
76
|
+
// `allowedFor('constructor')`, so a matrix asserting on an actor of that name read as allow.
|
|
77
|
+
allowedFor: (name) => (Object.hasOwn(verdicts, name) ? verdicts[name] === true : false),
|
|
71
78
|
toTable: () =>
|
|
72
79
|
rows
|
|
73
80
|
.map((row) =>
|
|
@@ -77,16 +84,39 @@ export const policyMatrix = <I, R = unknown>(
|
|
|
77
84
|
};
|
|
78
85
|
};
|
|
79
86
|
|
|
80
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Builds a test actor through `userActor()` — core's own builder, and now the only one, since
|
|
89
|
+
* `permissions` moved to core's `Actor` (`As of 2026-08-19`).
|
|
90
|
+
*
|
|
91
|
+
* This function hand-rolled the object literal because it had to: a direct grant was declared on
|
|
92
|
+
* policy's `PolicyActorFields` and core's builder had no field for it. That is what made every
|
|
93
|
+
* actor it minted structurally different from a request's — it omitted `kind` and `scopes` behind
|
|
94
|
+
* an `as unknown as Actor`, so `hasScope()` threw a bare `TypeError` and `actorLabel()` rendered
|
|
95
|
+
* `undefined:editor` into logs and spans, and a generated policy test (`x g policy`) asserting a
|
|
96
|
+
* scope-gated denial failed as a 500-shaped throw rather than as the denial it wrote.
|
|
97
|
+
*
|
|
98
|
+
* Going through `userActor()` closes that by construction rather than by keeping a second field
|
|
99
|
+
* list in sync: a field added to `Actor` arrives here, and the result is FROZEN with frozen
|
|
100
|
+
* arrays, exactly as the actor `@ultimat3/auth` resolves per request is.
|
|
101
|
+
*/
|
|
81
102
|
export const testActor = (
|
|
82
103
|
name: string,
|
|
83
|
-
init: {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
init: {
|
|
105
|
+
roles?: readonly string[];
|
|
106
|
+
permissions?: readonly string[];
|
|
107
|
+
scopes?: readonly string[];
|
|
108
|
+
orgId?: string;
|
|
109
|
+
} = {},
|
|
110
|
+
): NamedActor => {
|
|
111
|
+
const built = userActor({
|
|
87
112
|
id: name,
|
|
88
113
|
roles: init.roles ?? [],
|
|
114
|
+
scopes: init.scopes ?? [],
|
|
89
115
|
permissions: init.permissions ?? [],
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
116
|
+
});
|
|
117
|
+
// `orgId: null` is deliberate and load-bearing, and the ONE cast left. Core declares
|
|
118
|
+
// `orgId?: string | undefined`, so nothing typed can mint the `null` an app's own adapter still
|
|
119
|
+
// puts on the wire — and `@ultimat3/query`'s `orgless()` treats `null`, `undefined` and `''`
|
|
120
|
+
// alike precisely because it does reach there. Frozen, so this stays a production-shaped actor.
|
|
121
|
+
return { name, actor: Object.freeze({ ...built, orgId: init.orgId ?? null }) as Actor };
|
|
122
|
+
};
|