@ultimat3/policy 24.0.0 → 25.1.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 +6 -2
- package/README.md +3 -1
- package/package.json +3 -3
- package/src/declaration-site.ts +27 -14
- package/src/errors.ts +3 -3
- package/src/grant-index.ts +12 -7
- package/src/index.ts +13 -2
- package/src/policy.ts +7 -0
- package/src/roles.ts +7 -7
- package/src/test-kit.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -66,7 +66,7 @@ two differ, and it is why a surface that decides on input alone needs no edit.
|
|
|
66
66
|
`definePolicy` `check` returning anything else — `undefined`, `1`, `{ allowed: 'yes' }` — DENIES;
|
|
67
67
|
it used to reach `decision.allowed` as a bare `TypeError`, and a truthy `allowed` read as ALLOWED.
|
|
68
68
|
- **A denial keeps its code everywhere.** `assertAllowed` throws `codeOf(decision)` (`denialError`:
|
|
69
|
-
`X_FORBIDDEN` through `
|
|
69
|
+
`X_FORBIDDEN` through `policyForbidden()`, any other code as a `PolicyDenialError`) and
|
|
70
70
|
`HttpDenial.status` follows it — 401 for `X_UNAUTHENTICATED`, 403 otherwise. `X_UNAUTHENTICATED`
|
|
71
71
|
is `@ultimat3/auth`'s (`POLICY_BORROWED_ERROR_CODES`): named here, titled there.
|
|
72
72
|
- **`definePermissions()` merges, and an EMPTY registry is permissive.** It only ever `add`s, so a
|
|
@@ -111,6 +111,10 @@ two differ, and it is why a surface that decides on input alone needs no edit.
|
|
|
111
111
|
which this file and `policy-anonymous.ts` both document as ALLOWED, and it states the wrong rule —
|
|
112
112
|
`not` inverts a decision about grants, and whether one was made without an actor is `or`'s to
|
|
113
113
|
report.
|
|
114
|
+
- **A wildcard grant reaches its PREFIX, never its first segment**: `grantMatches` and the grant
|
|
115
|
+
index both ask core's `grantCovers` (`auth` cuts API keys with it too — same tier, so the one
|
|
116
|
+
copy lives in core). `billing:invoice:*` never grants `billing:refund:issue`
|
|
117
|
+
(`grant-wildcard.test.ts`). `resourceOf` is for grouping (`byResource`), never for coverage.
|
|
114
118
|
- **`defineRoles()` merges** and refuses a role two modules define differently
|
|
115
119
|
(`X_ROLE_REDEFINED`, naming both declaration sites). A re-declaration of an *identical*
|
|
116
120
|
role is a no-op, which is what keeps `defineRoles({ ...roleDefinitions(), … })` legal.
|
|
@@ -177,7 +181,7 @@ reappearing there is a failing test.
|
|
|
177
181
|
| `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
|
|
178
182
|
| `declaration-site.ts` | the caller's stack frame a role or permission declaration records |
|
|
179
183
|
| `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
|
|
180
|
-
| `test-kit.ts` | `policyMatrix()` for generated policy tests, and `testActor()` — whose `.actor` is typed `Actor`, never `null`, so it goes straight into `
|
|
184
|
+
| `test-kit.ts` | `policyMatrix()` for generated policy tests, and `testActor()` — whose `.actor` is typed `Actor`, never `null`, so it goes straight into `ctxOf({ actor })` |
|
|
181
185
|
|
|
182
186
|
## The hot path
|
|
183
187
|
|
package/README.md
CHANGED
|
@@ -151,7 +151,9 @@ dispatch failed **closed with a `SurfaceDenial` no caller could read**.
|
|
|
151
151
|
`PermissionRegistry` (which `x g policy` generates) makes a typo a compile error, and
|
|
152
152
|
`can()` throws `X_PERMISSION_UNKNOWN` at declaration time either way. Roles are sugar:
|
|
153
153
|
`defineRoles({ owner: { grants: ['post:delete'], inherits: ['editor'] } })` expands
|
|
154
|
-
depth-first to a flat set, cycles included. `post:*` and `*` are supported.
|
|
154
|
+
depth-first to a flat set, cycles included. `post:*` and `*` are supported. A wildcard reaches
|
|
155
|
+
its PREFIX and nothing beside it: `billing:invoice:*` grants `billing:invoice:read`, never
|
|
156
|
+
`billing:refund:issue` (`@ultimat3/core`'s `grantCovers`, the one reading `auth` shares).
|
|
155
157
|
|
|
156
158
|
`defineRoles()` **merges** into the app's one role map. A second call in a new feature folder
|
|
157
159
|
adds roles; it never deletes the first module's. A role two modules define *differently* is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/policy",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.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.1.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/declaration-site.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
export const UNKNOWN_SITE = 'unknown site';
|
|
5
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
|
+
|
|
6
9
|
/**
|
|
7
10
|
* This package's own source directory — `src/` in the workspace, `dist/` or `node_modules/…` once
|
|
8
11
|
* installed. A frame is internal by the FILE it runs in (one of the three below), never by a name it spells: matching
|
|
@@ -10,19 +13,32 @@ export const UNKNOWN_SITE = 'unknown site';
|
|
|
10
13
|
* `definePermissionsForPosts`, and the site became whichever frame came next — a layout, or a
|
|
11
14
|
* module outside the app root that the `policy` step then read as a package's declaration.
|
|
12
15
|
*/
|
|
13
|
-
const OWN_DIR =
|
|
16
|
+
const OWN_DIR = import.meta.dir;
|
|
14
17
|
|
|
15
18
|
/** The three implementation files a declaration passes through — a test beside them is a caller. */
|
|
16
|
-
const
|
|
17
|
-
(name) => `${OWN_DIR}${name}.`,
|
|
18
|
-
);
|
|
19
|
+
const INTERNAL_NAMES = ['declaration-site', 'permissions', 'roles'] as const;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
};
|
|
26
42
|
|
|
27
43
|
/**
|
|
28
44
|
* The first frame outside this package. Not a throw: an `Error` is built only to read its stack,
|
|
@@ -31,8 +47,5 @@ const isInternal = (frame: string): boolean =>
|
|
|
31
47
|
*/
|
|
32
48
|
export const callerSite = (): string => {
|
|
33
49
|
const stack = new Error().stack;
|
|
34
|
-
|
|
35
|
-
const frames = stack.split('\n').slice(1);
|
|
36
|
-
const chosen = frames.find((line) => !isInternal(line)) ?? frames[0] ?? '';
|
|
37
|
-
return chosen.trim() === '' ? UNKNOWN_SITE : chosen.trim();
|
|
50
|
+
return stack === undefined ? UNKNOWN_SITE : pickCallerSite(stack, OWN_DIR);
|
|
38
51
|
};
|
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}`,
|
|
@@ -128,13 +128,13 @@ export class PolicyDenialError extends UltimateError {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* The error a denial is thrown as: `X_FORBIDDEN` through `
|
|
131
|
+
* The error a denial is thrown as: `X_FORBIDDEN` through `policyForbidden()`, anything else under its
|
|
132
132
|
* own code. `assertAllowed` threw `X_FORBIDDEN` for every denial, so an anonymous caller read
|
|
133
133
|
* "denied" where every adapter reports `X_UNAUTHENTICATED` — the code a 401 and a sign-in
|
|
134
134
|
* redirect key on.
|
|
135
135
|
*/
|
|
136
136
|
export const denialError = (label: string, reason: string, code: string): UltimateError => {
|
|
137
|
-
if (code === 'X_FORBIDDEN') return
|
|
137
|
+
if (code === 'X_FORBIDDEN') return policyForbidden(label, reason);
|
|
138
138
|
const subject = BARE_PERMISSION.test(label)
|
|
139
139
|
? `x policy explain ${label} --json`
|
|
140
140
|
: 'x policy list --json';
|
package/src/grant-index.ts
CHANGED
|
@@ -2,18 +2,23 @@
|
|
|
2
2
|
// stays put. Why it earns a file: a live query evaluates policy PER SUBSCRIBER on every change
|
|
3
3
|
// event, and every `can()` clause asks this question — so an unmemoised expansion turned one
|
|
4
4
|
// write on a channel with 10k subscribers into 10k role-graph walks in a single tick.
|
|
5
|
-
import {
|
|
5
|
+
import { grantCovers } from '@ultimat3/core';
|
|
6
|
+
import type { Permission } from './permissions';
|
|
6
7
|
import { type Actor, expandRoles, type RoleMap, roleDefinitions, roleMapGeneration } from './roles';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The three shapes `grantMatches()` can take, pre-split so a lookup is a `Set.has` instead of a
|
|
10
|
-
* scan: an exact grant, a `<
|
|
11
|
+
* scan: an exact grant, a `<prefix>:*` wildcard, and the bare `*`. Equivalent to
|
|
11
12
|
* `actorPermissions(actor).some((grant) => grantMatches(grant, permission))`, per grant kind.
|
|
12
13
|
*/
|
|
13
14
|
interface GrantIndex {
|
|
14
15
|
readonly exact: ReadonlySet<string>;
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The `<prefix>:*` grants themselves, asked through core's `grantCovers` — a list, not a set of
|
|
18
|
+
* resources: keyed by the first segment, `billing:invoice:*` answered for `billing:refund:issue`.
|
|
19
|
+
* An actor holds a handful, so the scan is a few `startsWith`.
|
|
20
|
+
*/
|
|
21
|
+
readonly wildcards: readonly string[];
|
|
17
22
|
readonly all: boolean;
|
|
18
23
|
/** Deduped and sorted, built once so `actorPermissions()` never sorts per clause. */
|
|
19
24
|
readonly sorted: readonly string[];
|
|
@@ -39,11 +44,11 @@ const buildIndex = (actor: Actor, map: RoleMap): GrantIndex => {
|
|
|
39
44
|
// read that throws where it should have DENIED is the `testActor` defect one layer down.
|
|
40
45
|
const exact = new Set<string>(actor.permissions ?? []);
|
|
41
46
|
for (const grant of expandRoles(actor.roles ?? [], map)) exact.add(grant);
|
|
42
|
-
const wildcards =
|
|
47
|
+
const wildcards: string[] = [];
|
|
43
48
|
let all = false;
|
|
44
49
|
for (const grant of exact) {
|
|
45
50
|
if (grant === '*') all = true;
|
|
46
|
-
else if (grant.endsWith(':*')) wildcards.
|
|
51
|
+
else if (grant.endsWith(':*')) wildcards.push(grant);
|
|
47
52
|
}
|
|
48
53
|
return { exact, wildcards, all, sorted: [...exact].sort() };
|
|
49
54
|
};
|
|
@@ -78,5 +83,5 @@ export const actorHas = (
|
|
|
78
83
|
if (actor === null) return false;
|
|
79
84
|
const index = indexFor(actor, map);
|
|
80
85
|
if (index.all || index.exact.has(permission)) return true;
|
|
81
|
-
return index.wildcards.
|
|
86
|
+
return index.wildcards.some((grant) => grantCovers(grant, permission));
|
|
82
87
|
};
|
package/src/index.ts
CHANGED
|
@@ -13,13 +13,13 @@ export type { PolicyErrorCode } from './errors';
|
|
|
13
13
|
export {
|
|
14
14
|
denialError,
|
|
15
15
|
emptyClauseList,
|
|
16
|
-
forbidden,
|
|
17
16
|
POLICY_BORROWED_ERROR_CODES,
|
|
18
17
|
POLICY_ERROR_CODES,
|
|
19
18
|
POLICY_ERROR_TITLES,
|
|
20
19
|
PolicyDenialError,
|
|
21
20
|
PolicyError,
|
|
22
21
|
permissionUnknown,
|
|
22
|
+
policyForbidden,
|
|
23
23
|
policyMissing,
|
|
24
24
|
roleRedefined,
|
|
25
25
|
} from './errors';
|
|
@@ -59,7 +59,18 @@ export type {
|
|
|
59
59
|
Recorder,
|
|
60
60
|
TraceEntry,
|
|
61
61
|
} from './policy';
|
|
62
|
-
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';
|
|
63
74
|
export { admitsAnonymous } from './policy-anonymous';
|
|
64
75
|
export type { PreInputArgs } from './pre-input';
|
|
65
76
|
export { decideBeforeInput, enforceBeforeInput } from './pre-input';
|
package/src/policy.ts
CHANGED
|
@@ -197,6 +197,13 @@ export const policyPermissions = <I = unknown, R = unknown>(
|
|
|
197
197
|
policy: Policy<I, R>,
|
|
198
198
|
): readonly Permission[] => policy.permissions;
|
|
199
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
|
+
|
|
200
207
|
const flatten = (children: readonly { readonly permissions: readonly Permission[] }[]) =>
|
|
201
208
|
[...new Set(children.flatMap((child) => child.permissions))].sort();
|
|
202
209
|
|
package/src/roles.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
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 { grantCovers } from '@ultimat3/core';
|
|
6
7
|
import { callerSite, UNKNOWN_SITE } from './declaration-site';
|
|
7
8
|
import { roleRedefined } from './errors';
|
|
8
|
-
import { resourceOf } from './permissions';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* An **alias** of core's declaration, and nothing more (`As of 2026-08-19`) — so the actor
|
|
@@ -156,12 +156,12 @@ export const expandRoles = (
|
|
|
156
156
|
return [...out].sort();
|
|
157
157
|
};
|
|
158
158
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
/**
|
|
160
|
+
* `post:*` matches every name under `post:`; `billing:invoice:*` only those under
|
|
161
|
+
* `billing:invoice:`; `*` matches everything. Core's `grantCovers` is the one reading — `auth`
|
|
162
|
+
* cuts an API key to its owner with it too, and the two may not share a copy at tier 2.
|
|
163
|
+
*/
|
|
164
|
+
export const grantMatches = (grant: string, wanted: string): boolean => grantCovers(grant, wanted);
|
|
165
165
|
|
|
166
166
|
/** For the `/_x` dashboard: which roles would satisfy a permission. */
|
|
167
167
|
export const rolesGranting = (permission: string, map: RoleMap = roleMap): readonly string[] =>
|
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
|
*/
|