@ultimat3/policy 1.2.0 → 2.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 +101 -0
- package/README.md +55 -3
- package/package.json +3 -2
- package/src/decisions.ts +90 -0
- package/src/errors.ts +18 -3
- package/src/evaluate.ts +53 -4
- package/src/grant-index.ts +79 -0
- package/src/index.ts +33 -7
- package/src/policy.ts +31 -3
- package/src/roles.ts +68 -18
- package/src/surfaces.ts +7 -4
- package/src/test-kit.ts +12 -6
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @ultimat3/policy
|
|
2
|
+
|
|
3
|
+
The one authz rule, evaluated in every surface. Tier 2.
|
|
4
|
+
|
|
5
|
+
## Boundary
|
|
6
|
+
|
|
7
|
+
- May import: `@ultimat3/core`. That is all it needs.
|
|
8
|
+
- Never import `@ultimat3/http`/`@ultimat3/entity` (same tier) or any surface package.
|
|
9
|
+
Surface denial shapes are declared structurally in `surfaces.ts`.
|
|
10
|
+
|
|
11
|
+
## The one predicate signature
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
interface PolicyArgs<I = unknown, R = unknown> {
|
|
15
|
+
readonly input: I;
|
|
16
|
+
readonly actor: Actor | null;
|
|
17
|
+
readonly row: R | null; // required; `null` = this rule decides on input alone
|
|
18
|
+
readonly ctx?: Ctx;
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Every predicate on every surface sees these four fields. `row` is `R | null` rather than
|
|
23
|
+
`row?: R` on purpose — an optional field lets a surface forget it and lets the two shapes
|
|
24
|
+
drift apart again, which is exactly how the realtime row gate ended up nesting the row
|
|
25
|
+
inside `input`.
|
|
26
|
+
|
|
27
|
+
`EvaluateArgs<I, R>` is what *callers* pass and its `row` **is** optional; `evaluate()`
|
|
28
|
+
normalises a missing row to `null` before the predicate sees it. That is the only place the
|
|
29
|
+
two differ, and it is why a surface that decides on input alone needs no edit.
|
|
30
|
+
|
|
31
|
+
## Rules
|
|
32
|
+
|
|
33
|
+
- **Never add a second authz path.** If a surface cannot use `evaluate()`, add an
|
|
34
|
+
adapter to `surfaces.ts` — nothing else.
|
|
35
|
+
- **One predicate shape.** A row-level rule reads `args.row`. Never pass a row through
|
|
36
|
+
`input`, and never add a per-surface args type.
|
|
37
|
+
- A policy is pure and synchronous. No I/O, no `await`. Load the row first, then decide.
|
|
38
|
+
- `reason` must be safe to log: name permissions and clauses, never row data or PII —
|
|
39
|
+
`row` being in scope changes nothing about what a denial is allowed to say. The same
|
|
40
|
+
guarantee is what a `DecisionSink` event inherits: `decisions.ts` carries the label, the
|
|
41
|
+
deciding clause and the actor's identifiers, and **never `row` or `input`**.
|
|
42
|
+
- A missing policy is refused by the **type system**, not by a throw: `ActionDef.policy`
|
|
43
|
+
is a required field in `@ultimat3/action`, so an action without one does not compile.
|
|
44
|
+
`X_POLICY_MISSING` / `policyMissing()` stay published for a declaration site that cannot
|
|
45
|
+
say it in a type (a config-driven route table, a policy resolved by name). Never default
|
|
46
|
+
to allow.
|
|
47
|
+
- `can()` validates its permission at declaration time, not at request time.
|
|
48
|
+
- **`not()` never inverts `X_UNAUTHENTICATED`.** A null actor is not a fact about this
|
|
49
|
+
actor's grants; inverting it makes `not(can('order:internal'))` a public door into the
|
|
50
|
+
internal one. Any denial carrying that code propagates unchanged.
|
|
51
|
+
- **`defineRoles()` merges** and refuses a role two modules define differently
|
|
52
|
+
(`X_ROLE_REDEFINED`, naming both declaration sites). A re-declaration of an *identical*
|
|
53
|
+
role is a no-op, which is what keeps `defineRoles({ ...roleDefinitions(), … })` legal.
|
|
54
|
+
- No `any`. Never throw a bare `Error` — use `errors.ts`.
|
|
55
|
+
- **This package owns `X_FORBIDDEN`** and registers its title with core. `http`, `auth`
|
|
56
|
+
and every surface adapter reuse the code and must not re-register it.
|
|
57
|
+
|
|
58
|
+
## The one authz rule — and the one honest exception
|
|
59
|
+
|
|
60
|
+
Actions, queries, jobs, MCP tools **and routes** all resolve their rule through `evaluate()`.
|
|
61
|
+
There is no second door. `@ultimat3/auth`'s `requireRole()` / `requireScope()` were one until
|
|
62
|
+
1.3.0 — they asserted on the ambient actor and never evaluated a policy, and a route gated that
|
|
63
|
+
way was invisible to `x policy list`, to `framework.manifest.json` and to `openapi.json`. They
|
|
64
|
+
are deleted: in the repo's whole history, and in both tracked apps, **nothing ever called them**,
|
|
65
|
+
so the framework shipped a documented invitation to an under-reported route and nobody accepted
|
|
66
|
+
it. `packages/auth/src/guards.test.ts` pins that module's export list, so an authz decision
|
|
67
|
+
reappearing there is a failing test.
|
|
68
|
+
|
|
69
|
+
## Files
|
|
70
|
+
|
|
71
|
+
| File | Job |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording |
|
|
74
|
+
| `evaluate.ts` | the single entry point; builds the trace, emits the one decision event |
|
|
75
|
+
| `decisions.ts` | the `DecisionSink` seam — no-op default, one call site, never PII |
|
|
76
|
+
| `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
|
|
77
|
+
| `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
|
|
78
|
+
| `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
|
|
79
|
+
| `test-kit.ts` | `policyMatrix()` for generated policy tests |
|
|
80
|
+
|
|
81
|
+
## The hot path
|
|
82
|
+
|
|
83
|
+
A live query evaluates policy **per subscriber on every change event** — one write to a channel
|
|
84
|
+
with 10k watchers is 10k evaluations in one tick. Two consequences, both load-bearing:
|
|
85
|
+
|
|
86
|
+
- `grant-index.ts` memoises the flattened grant set on a `WeakMap<Actor, …>`, invalidated by
|
|
87
|
+
`roleMapGeneration()` — the same shape `@ultimat3/entity`'s `relationMap()` uses against
|
|
88
|
+
`registryGeneration()`. Keyed on the actor **object**, never on its id: `@ultimat3/auth`
|
|
89
|
+
re-reads the user row every request and mints a fresh frozen actor, so a revoked role still
|
|
90
|
+
takes effect on the next request and the entry dies with it. **Never cache an actor, or a
|
|
91
|
+
grant set, across requests** — that is the stale-authz window the framework does not have.
|
|
92
|
+
- The trace is opt-in: on outside production, and in production only once a `DecisionSink` is
|
|
93
|
+
installed. `evaluate(policy, args, { trace: true })` forces it — `policyMatrix()` does,
|
|
94
|
+
because `deciding` *is* the matrix.
|
|
95
|
+
|
|
96
|
+
## Commands
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
bun test packages/policy
|
|
100
|
+
bun run --filter @ultimat3/policy typecheck
|
|
101
|
+
```
|
package/README.md
CHANGED
|
@@ -12,6 +12,15 @@ export const publishPost = action({
|
|
|
12
12
|
});
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
**No exception, as of 1.3.0.** `@ultimat3/auth`'s `requireRole()` / `requireScope()` used to gate
|
|
16
|
+
*routes* on the ambient actor without evaluating a policy; they are deleted. They were documented
|
|
17
|
+
here as "one honest exception" and had **zero callers** in the framework or in either tracked app —
|
|
18
|
+
a sanctioned second door nobody walked through, whose own documentation admitted that a route
|
|
19
|
+
gated that way is invisible to `x policy list`, to `framework.manifest.json` and to `openapi.json`.
|
|
20
|
+
Gate a route with a `Policy`: `can('admin:access')`, which every introspection surface can read.
|
|
21
|
+
`requireActor()` and `currentActor()` remain — those assert *authentication*, which is what
|
|
22
|
+
`@ultimat3/auth` produces.
|
|
23
|
+
|
|
15
24
|
## Shape
|
|
16
25
|
|
|
17
26
|
A policy is a pure `(input, actor, row, ctx) => PolicyDecision`.
|
|
@@ -63,7 +72,17 @@ missing row to `null`. A surface that has no row passes `{ input, actor, ctx }`
|
|
|
63
72
|
| `allow()` / `deny(reason)` | terminal; `allow()` is how "public" is said out loud |
|
|
64
73
|
| `and(...)` | first denial wins, its reason is the reason |
|
|
65
74
|
| `or(...)` | first allowance wins; otherwise the last denial is reported |
|
|
66
|
-
| `not(p)` | inverts |
|
|
75
|
+
| `not(p)` | inverts — except `X_UNAUTHENTICATED`, which propagates unchanged |
|
|
76
|
+
|
|
77
|
+
`not()` never turns "there is no actor" into an allow. `can()` denies a null actor with
|
|
78
|
+
`X_UNAUTHENTICATED`, and inverting that would make `not(can('order:internal'))` — the natural
|
|
79
|
+
simplification of `and(can('order:read'), not(can('order:internal')))` — a public door into the
|
|
80
|
+
internal one.
|
|
81
|
+
|
|
82
|
+
`policy.permissions` (or `policyPermissions(policy)`) is the flattened, deduped, sorted list of
|
|
83
|
+
every permission a tree references, `not()` clauses included. It is what a compliance report has
|
|
84
|
+
to read: `label` renders a composite as `and(post:publish, org:administer)`, which is a sentence,
|
|
85
|
+
never a permission.
|
|
67
86
|
|
|
68
87
|
## Four surfaces, four adapters, one rule
|
|
69
88
|
|
|
@@ -87,6 +106,15 @@ Adding a fifth surface means adding an adapter here **and nothing else**.
|
|
|
87
106
|
`defineRoles({ owner: { grants: ['post:delete'], inherits: ['editor'] } })` expands
|
|
88
107
|
depth-first to a flat set, cycles included. `post:*` and `*` are supported.
|
|
89
108
|
|
|
109
|
+
`defineRoles()` **merges** into the app's one role map. A second call in a new feature folder
|
|
110
|
+
adds roles; it never deletes the first module's. A role two modules define *differently* is
|
|
111
|
+
`X_ROLE_REDEFINED`, naming both declaration sites — and an identical re-declaration is a no-op,
|
|
112
|
+
so `defineRoles({ ...roleDefinitions(), … })` stays legal.
|
|
113
|
+
|
|
114
|
+
The flattened grant set is memoised per actor and invalidated the moment the role map changes.
|
|
115
|
+
It is keyed on the actor object, so it lives exactly as long as the request does: `@ultimat3/auth`
|
|
116
|
+
re-reads the user row every request, and a revoked role takes effect on the next one.
|
|
117
|
+
|
|
90
118
|
## Traces
|
|
91
119
|
|
|
92
120
|
`evaluate()` returns a depth-first trace naming the clause that decided. `/_x` renders
|
|
@@ -99,10 +127,34 @@ editor deny post:read predicate returned false
|
|
|
99
127
|
viewer deny actor lacks post:publish
|
|
100
128
|
```
|
|
101
129
|
|
|
130
|
+
Building it is opt-in: on outside production, and in production only once a decision sink is
|
|
131
|
+
installed. A live query evaluates policy per subscriber on every change event, so an unread
|
|
132
|
+
`TraceEntry[]` per evaluation is real allocation on the busiest path there is. Force it with
|
|
133
|
+
`evaluate(policy, args, { trace: true })`.
|
|
134
|
+
|
|
135
|
+
## The decision log
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
setDecisionSink({
|
|
139
|
+
record(event) {
|
|
140
|
+
// { label, allowed, code, reason, actorId, actorKind, orgId, surface, deciding }
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
No-op until installed, emitted from **one place** — inside `evaluate()`, so a fifth surface
|
|
146
|
+
inherits it — and it records the **allow** as well as the denial, which is the half an access
|
|
147
|
+
review actually asks for. It never carries `row` or `input`: `reason` is safe to log by
|
|
148
|
+
construction, and the sink inherits that guarantee. A sink that throws is logged and swallowed;
|
|
149
|
+
it never turns an allowed request into a 500.
|
|
150
|
+
|
|
102
151
|
## Errors
|
|
103
152
|
|
|
104
|
-
`X_FORBIDDEN` · `X_POLICY_MISSING`
|
|
105
|
-
|
|
153
|
+
`X_FORBIDDEN` · `X_POLICY_MISSING` · `X_PERMISSION_UNKNOWN` · `X_ROLE_REDEFINED`
|
|
154
|
+
|
|
155
|
+
A missing policy is a **type** error, not a throw: `ActionDef.policy` is required, so an action
|
|
156
|
+
without one does not compile. `policyMissing()` stays for a declaration site that cannot say it
|
|
157
|
+
in a type — a config-driven route table, a policy resolved by name.
|
|
106
158
|
|
|
107
159
|
## Boundaries
|
|
108
160
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/policy",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "The one authz rule, evaluated identically in every surface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,6 +31,6 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "2.0.0"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/src/decisions.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The authz decision log's one seam. Shaped exactly like core's `ErrorReporter`: always on, a
|
|
2
|
+
// no-op by default, the wire format supplied by a sink and never here. It exists because the
|
|
3
|
+
// question after an incident is "who did this, and which rule let them?" — and until now an
|
|
4
|
+
// allowed decision left no trace at all, on any of the four surfaces.
|
|
5
|
+
//
|
|
6
|
+
// PII rule, inherited and not negotiable: an event carries the label, the clause and the actor's
|
|
7
|
+
// identifiers. Never `row`, never `input`. `reason` is already safe to log by construction
|
|
8
|
+
// (policy/CLAUDE.md) and this is the guarantee that keeps it that way.
|
|
9
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
10
|
+
import type { Surface } from './surfaces';
|
|
11
|
+
|
|
12
|
+
export interface PolicyDecisionEvent {
|
|
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
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read by `evaluate()` before it builds an event, and by the trace default: a sink is the one
|
|
52
|
+
* reason to keep building a trace in production.
|
|
53
|
+
*/
|
|
54
|
+
export const decisionSinkInstalled = (): boolean => sink !== undefined;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Called from exactly ONE place — inside `evaluate()`, so a fifth surface inherits the log the
|
|
58
|
+
* day it is added rather than the day someone remembers to wire it. Never throws: a sink that is
|
|
59
|
+
* down must not turn an allowed request into a 500.
|
|
60
|
+
*/
|
|
61
|
+
export const emitDecision = (event: PolicyDecisionEvent): void => {
|
|
62
|
+
if (sink === undefined) return;
|
|
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
|
+
};
|
|
90
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
// The policy layer's stable error codes. `X_POLICY_MISSING` is
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The policy layer's stable error codes. `X_POLICY_MISSING` is enforced by the TYPE system,
|
|
2
|
+
// not by a throw: `ActionDef.policy` is a required field (`@ultimat3/action`'s `action.ts`), so
|
|
3
|
+
// an action with no policy never compiles. The code and its factory stay published for a
|
|
4
|
+
// declaration site that cannot express the requirement in a type — a config-driven route table,
|
|
5
|
+
// a policy resolved by name — and `policyMissing()` is how such a site says it.
|
|
4
6
|
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
7
|
|
|
6
8
|
export const POLICY_ERROR_CODES = [
|
|
7
9
|
'X_FORBIDDEN',
|
|
8
10
|
'X_POLICY_MISSING',
|
|
9
11
|
'X_PERMISSION_UNKNOWN',
|
|
12
|
+
'X_ROLE_REDEFINED',
|
|
10
13
|
] as const;
|
|
11
14
|
|
|
12
15
|
export type PolicyErrorCode = (typeof POLICY_ERROR_CODES)[number];
|
|
@@ -15,6 +18,7 @@ export const POLICY_ERROR_TITLES: Readonly<Record<PolicyErrorCode, string>> = {
|
|
|
15
18
|
X_FORBIDDEN: 'policy denied this actor',
|
|
16
19
|
X_POLICY_MISSING: 'an action was declared without a policy',
|
|
17
20
|
X_PERMISSION_UNKNOWN: 'permission string is not in the permission set',
|
|
21
|
+
X_ROLE_REDEFINED: 'two modules define the same role differently',
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
// This package OWNS X_FORBIDDEN — http, auth, ai, realtime and every other surface adapter throw
|
|
@@ -53,6 +57,17 @@ export const policyMissing = (subject: string): PolicyError =>
|
|
|
53
57
|
fix: `add policy: can('<resource>:<verb>') to ${subject}, or allow('public') to say so explicitly`,
|
|
54
58
|
});
|
|
55
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Both declaration sites are named because the fix is always "one of these two wins", and which
|
|
62
|
+
* two is the only thing the author does not already know.
|
|
63
|
+
*/
|
|
64
|
+
export const roleRedefined = (role: string, first: string, second: string): PolicyError =>
|
|
65
|
+
new PolicyError({
|
|
66
|
+
code: 'X_ROLE_REDEFINED',
|
|
67
|
+
cause: `role "${role}" is defined twice with different grants — first at ${first}, again at ${second}`,
|
|
68
|
+
fix: `x policy list --json # then keep ONE definition of "${role}": rename the second, or fold its grants into the first`,
|
|
69
|
+
});
|
|
70
|
+
|
|
56
71
|
export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError =>
|
|
57
72
|
new PolicyError({
|
|
58
73
|
code: 'X_PERMISSION_UNKNOWN',
|
package/src/evaluate.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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
|
-
|
|
4
|
+
// It is also the one place a decision reaches the `DecisionSink` — allowed decisions
|
|
5
|
+
// included, since "who was let in" is the half an audit actually needs.
|
|
6
|
+
import { type Ctx, DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
|
|
7
|
+
import { decisionSinkInstalled, emitDecision } from './decisions';
|
|
5
8
|
import type { Policy, PolicyDecision, TraceEntry } from './policy';
|
|
6
9
|
import type { Actor } from './roles';
|
|
10
|
+
import type { Surface } from './surfaces';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* What a *caller* supplies. `row` is optional here and required in `PolicyArgs`: a surface
|
|
@@ -18,21 +22,52 @@ export interface EvaluateArgs<I, R = unknown> {
|
|
|
18
22
|
readonly ctx?: Ctx;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
export interface EvaluateOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Build the trace. Defaults to on outside production, and to on in production only once a
|
|
28
|
+
* `DecisionSink` is installed — a `TraceEntry[]` per evaluation is real allocation on the
|
|
29
|
+
* live-query path, where one write fans out to one evaluation per subscriber.
|
|
30
|
+
*/
|
|
31
|
+
readonly trace?: boolean | undefined;
|
|
32
|
+
/** Which adapter asked. Carried to the sink; the decision itself never depends on it. */
|
|
33
|
+
readonly surface?: Surface | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export interface PolicyEvaluation {
|
|
22
37
|
readonly allowed: boolean;
|
|
23
38
|
readonly decision: PolicyDecision;
|
|
24
|
-
/** Depth-first, in evaluation order. Empty
|
|
39
|
+
/** Depth-first, in evaluation order. Empty for a policy that never ran, or a trace turned off. */
|
|
25
40
|
readonly trace: readonly TraceEntry[];
|
|
26
|
-
/** The clause whose result the caller is looking at. */
|
|
41
|
+
/** The clause whose result the caller is looking at. `null` when the trace is off. */
|
|
27
42
|
readonly deciding: TraceEntry | null;
|
|
28
43
|
readonly label: string;
|
|
29
44
|
}
|
|
30
45
|
|
|
46
|
+
let outsideProduction: boolean | undefined;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolved once and cached: `ULTIMATE_ENV` cannot change under a running process, and reading
|
|
50
|
+
* `process.env` per evaluation is exactly the per-subscriber cost this change exists to remove.
|
|
51
|
+
* Non-throwing on purpose — a malformed `ULTIMATE_ENV` is its own error with its own fix, and it
|
|
52
|
+
* must never be raised for the first time by an authz check.
|
|
53
|
+
*/
|
|
54
|
+
const traceByDefault = (): boolean => {
|
|
55
|
+
outsideProduction ??= (tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT) !== 'production';
|
|
56
|
+
return outsideProduction || decisionSinkInstalled();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Test seam: re-reads the environment on the next evaluation. */
|
|
60
|
+
export const resetPolicyTracing = (): void => {
|
|
61
|
+
outsideProduction = undefined;
|
|
62
|
+
};
|
|
63
|
+
|
|
31
64
|
export const evaluate = <I, R = unknown>(
|
|
32
65
|
policy: Policy<I, R>,
|
|
33
66
|
args: EvaluateArgs<I, R>,
|
|
67
|
+
options?: EvaluateOptions,
|
|
34
68
|
): PolicyEvaluation => {
|
|
35
69
|
const trace: TraceEntry[] = [];
|
|
70
|
+
const wanted = options?.trace ?? traceByDefault();
|
|
36
71
|
const decision = policy.run(
|
|
37
72
|
{
|
|
38
73
|
input: args.input,
|
|
@@ -43,7 +78,8 @@ export const evaluate = <I, R = unknown>(
|
|
|
43
78
|
row: args.row ?? null,
|
|
44
79
|
...(args.ctx === undefined ? {} : { ctx: args.ctx }),
|
|
45
80
|
},
|
|
46
|
-
|
|
81
|
+
// No recorder at all when the trace is off, so not even the closure is allocated.
|
|
82
|
+
wanted ? (entry) => trace.push(entry) : undefined,
|
|
47
83
|
);
|
|
48
84
|
// Entries are recorded post-order (children before their combinator), so the first
|
|
49
85
|
// entry that agrees with the outcome is the leaf that actually decided — and when a
|
|
@@ -55,6 +91,19 @@ export const evaluate = <I, R = unknown>(
|
|
|
55
91
|
) ??
|
|
56
92
|
trace.find(agrees) ??
|
|
57
93
|
null;
|
|
94
|
+
if (decisionSinkInstalled()) {
|
|
95
|
+
emitDecision({
|
|
96
|
+
label: policy.label,
|
|
97
|
+
allowed: decision.allowed,
|
|
98
|
+
code: decision.allowed ? null : decision.code,
|
|
99
|
+
reason: decision.allowed ? null : decision.reason,
|
|
100
|
+
actorId: args.actor?.id ?? null,
|
|
101
|
+
actorKind: args.actor?.kind ?? null,
|
|
102
|
+
orgId: args.actor?.orgId ?? null,
|
|
103
|
+
surface: options?.surface ?? null,
|
|
104
|
+
deciding: deciding?.label ?? null,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
58
107
|
return {
|
|
59
108
|
allowed: decision.allowed,
|
|
60
109
|
decision,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// One flattened grant set per actor, memoised for as long as the role map it was built from
|
|
2
|
+
// stays put. Why it earns a file: a live query evaluates policy PER SUBSCRIBER on every change
|
|
3
|
+
// event, and every `can()` clause asks this question — so an unmemoised expansion turned one
|
|
4
|
+
// write on a channel with 10k subscribers into 10k role-graph walks in a single tick.
|
|
5
|
+
import { type Permission, resourceOf } from './permissions';
|
|
6
|
+
import { type Actor, expandRoles, type RoleMap, roleDefinitions, roleMapGeneration } from './roles';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The three shapes `grantMatches()` can take, pre-split so a lookup is a `Set.has` instead of a
|
|
10
|
+
* scan: an exact grant, a `<resource>:*` wildcard, and the bare `*`. Equivalent to
|
|
11
|
+
* `actorPermissions(actor).some((grant) => grantMatches(grant, permission))`, per grant kind.
|
|
12
|
+
*/
|
|
13
|
+
interface GrantIndex {
|
|
14
|
+
readonly exact: ReadonlySet<string>;
|
|
15
|
+
/** Resources covered by a `<resource>:*` grant. */
|
|
16
|
+
readonly wildcards: ReadonlySet<string>;
|
|
17
|
+
readonly all: boolean;
|
|
18
|
+
/** Deduped and sorted, built once so `actorPermissions()` never sorts per clause. */
|
|
19
|
+
readonly sorted: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CacheEntry {
|
|
23
|
+
readonly map: RoleMap;
|
|
24
|
+
readonly generation: number;
|
|
25
|
+
readonly index: GrantIndex;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Keyed on the actor OBJECT, never on its id: `@ultimat3/auth` mints a fresh, frozen actor per
|
|
30
|
+
* request from a freshly-read user row, so the entry dies with the request and a revoked role
|
|
31
|
+
* still takes effect on the very next one. Caching by id — or across requests — would trade that
|
|
32
|
+
* property for the same allocations.
|
|
33
|
+
*/
|
|
34
|
+
const cache = new WeakMap<Actor, CacheEntry>();
|
|
35
|
+
|
|
36
|
+
const buildIndex = (actor: Actor, map: RoleMap): GrantIndex => {
|
|
37
|
+
const exact = new Set<string>(actor.permissions ?? []);
|
|
38
|
+
for (const grant of expandRoles(actor.roles ?? [], map)) exact.add(grant);
|
|
39
|
+
const wildcards = new Set<string>();
|
|
40
|
+
let all = false;
|
|
41
|
+
for (const grant of exact) {
|
|
42
|
+
if (grant === '*') all = true;
|
|
43
|
+
else if (grant.endsWith(':*')) wildcards.add(resourceOf(grant));
|
|
44
|
+
}
|
|
45
|
+
return { exact, wildcards, all, sorted: [...exact].sort() };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const indexFor = (actor: Actor, map: RoleMap): GrantIndex => {
|
|
49
|
+
const generation = roleMapGeneration();
|
|
50
|
+
const hit = cache.get(actor);
|
|
51
|
+
// The map is compared by reference as well as by generation, because both `actorHas()` and
|
|
52
|
+
// `actorPermissions()` take an explicit map override that the generation counter never sees.
|
|
53
|
+
if (hit !== undefined && hit.map === map && hit.generation === generation) return hit.index;
|
|
54
|
+
const index = buildIndex(actor, map);
|
|
55
|
+
cache.set(actor, { map, generation, index });
|
|
56
|
+
return index;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A COPY, per call. `readonly string[]` is compile-time only, so handing back `index.sorted` —
|
|
61
|
+
* which is the per-actor authz cache itself — let any caller `push` a grant into it through a
|
|
62
|
+
* widened reference and hold it for the life of that request. The list is small (an actor's own
|
|
63
|
+
* grants) and this is not on `actorHas`'s path, which reads the `Set` and copies nothing.
|
|
64
|
+
*/
|
|
65
|
+
export const actorPermissions = (
|
|
66
|
+
actor: Actor | null,
|
|
67
|
+
map: RoleMap = roleDefinitions(),
|
|
68
|
+
): readonly string[] => (actor === null ? [] : [...indexFor(actor, map).sorted]);
|
|
69
|
+
|
|
70
|
+
export const actorHas = (
|
|
71
|
+
actor: Actor | null,
|
|
72
|
+
permission: Permission,
|
|
73
|
+
map: RoleMap = roleDefinitions(),
|
|
74
|
+
): boolean => {
|
|
75
|
+
if (actor === null) return false;
|
|
76
|
+
const index = indexFor(actor, map);
|
|
77
|
+
if (index.all || index.exact.has(permission)) return true;
|
|
78
|
+
return index.wildcards.has(resourceOf(permission));
|
|
79
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
// The public surface of @ultimat3/policy. Explicit, never `export *`.
|
|
2
|
+
export type { DecisionSink, MemoryDecisionSink, PolicyDecisionEvent } from './decisions';
|
|
3
|
+
export {
|
|
4
|
+
decisionSinkInstalled,
|
|
5
|
+
memoryDecisionSink,
|
|
6
|
+
noopDecisionSink,
|
|
7
|
+
resetDecisionSink,
|
|
8
|
+
setDecisionSink,
|
|
9
|
+
} from './decisions';
|
|
1
10
|
export type { DefinePolicyInput } from './define';
|
|
2
11
|
export { definePolicy } from './define';
|
|
3
|
-
// The public surface of @ultimat3/policy. Explicit, never `export *`.
|
|
4
|
-
|
|
5
12
|
export type { PolicyErrorCode } from './errors';
|
|
6
13
|
export {
|
|
7
14
|
forbidden,
|
|
@@ -10,9 +17,18 @@ export {
|
|
|
10
17
|
PolicyError,
|
|
11
18
|
permissionUnknown,
|
|
12
19
|
policyMissing,
|
|
20
|
+
roleRedefined,
|
|
13
21
|
} from './errors';
|
|
14
|
-
export type { EvaluateArgs, PolicyEvaluation } from './evaluate';
|
|
15
|
-
export {
|
|
22
|
+
export type { EvaluateArgs, EvaluateOptions, PolicyEvaluation } from './evaluate';
|
|
23
|
+
export {
|
|
24
|
+
codeOf,
|
|
25
|
+
evaluate,
|
|
26
|
+
explain,
|
|
27
|
+
reasonOf,
|
|
28
|
+
renderTrace,
|
|
29
|
+
resetPolicyTracing,
|
|
30
|
+
} from './evaluate';
|
|
31
|
+
export { actorHas, actorPermissions } from './grant-index';
|
|
16
32
|
export type {
|
|
17
33
|
KnownPermission,
|
|
18
34
|
Permission,
|
|
@@ -37,16 +53,26 @@ export type {
|
|
|
37
53
|
Recorder,
|
|
38
54
|
TraceEntry,
|
|
39
55
|
} from './policy';
|
|
40
|
-
export {
|
|
56
|
+
export {
|
|
57
|
+
ALLOWED,
|
|
58
|
+
allow,
|
|
59
|
+
and,
|
|
60
|
+
can,
|
|
61
|
+
denied,
|
|
62
|
+
deny,
|
|
63
|
+
not,
|
|
64
|
+
or,
|
|
65
|
+
policyPermissions,
|
|
66
|
+
} from './policy';
|
|
41
67
|
export type { Actor, PolicyActorFields, RoleDef, RoleMap } from './roles';
|
|
42
68
|
export {
|
|
43
|
-
actorHas,
|
|
44
|
-
actorPermissions,
|
|
45
69
|
clearRoles,
|
|
46
70
|
defineRoles,
|
|
47
71
|
expandRoles,
|
|
48
72
|
grantMatches,
|
|
73
|
+
roleDeclarationSites,
|
|
49
74
|
roleDefinitions,
|
|
75
|
+
roleMapGeneration,
|
|
50
76
|
rolesGranting,
|
|
51
77
|
} from './roles';
|
|
52
78
|
export type {
|
package/src/policy.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
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 { actorHas } from './grant-index';
|
|
5
6
|
import { assertPermission, type KnownPermission, type Permission } from './permissions';
|
|
6
|
-
import {
|
|
7
|
+
import type { Actor } from './roles';
|
|
7
8
|
|
|
8
9
|
export type PolicyDecision =
|
|
9
10
|
| { readonly allowed: true }
|
|
@@ -141,6 +142,22 @@ export const deny = <I = unknown, R = unknown>(
|
|
|
141
142
|
},
|
|
142
143
|
});
|
|
143
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Every permission a policy tree references, deduped and sorted. This is the list a compliance
|
|
147
|
+
* report has to read: `label` renders a composite as `and(post:publish, org:administer)`, which
|
|
148
|
+
* is a sentence, not a permission — matching a grant against it reports every non-trivial rule's
|
|
149
|
+
* permissions as unenforced.
|
|
150
|
+
*
|
|
151
|
+
* A `not()` clause contributes its inner permissions too. The grant still decides the outcome,
|
|
152
|
+
* so "nothing references this permission" would be the false statement, not this one.
|
|
153
|
+
*/
|
|
154
|
+
export const policyPermissions = <I = unknown, R = unknown>(
|
|
155
|
+
policy: Policy<I, R>,
|
|
156
|
+
): readonly Permission[] => policy.permissions;
|
|
157
|
+
|
|
158
|
+
const flatten = (children: readonly { readonly permissions: readonly Permission[] }[]) =>
|
|
159
|
+
[...new Set(children.flatMap((child) => child.permissions))].sort();
|
|
160
|
+
|
|
144
161
|
// Combinators hand `args` to every child untouched — `row` included. A clause that rewrote
|
|
145
162
|
// the args would be the second authz shape all over again.
|
|
146
163
|
const combined = <I, R>(
|
|
@@ -151,7 +168,7 @@ const combined = <I, R>(
|
|
|
151
168
|
): Policy<I, R> => ({
|
|
152
169
|
kind,
|
|
153
170
|
label,
|
|
154
|
-
permissions: children
|
|
171
|
+
permissions: flatten(children),
|
|
155
172
|
children,
|
|
156
173
|
run(args, recorder, depth = 0) {
|
|
157
174
|
return record(recorder, this, depth, decide(args, recorder, depth + 1));
|
|
@@ -190,8 +207,19 @@ export const or = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy
|
|
|
190
207
|
},
|
|
191
208
|
);
|
|
192
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Inverts a decision about an actor's grants — and only that.
|
|
212
|
+
*
|
|
213
|
+
* `X_UNAUTHENTICATED` is propagated, never inverted. "There is no actor" is not a fact about
|
|
214
|
+
* this actor's permissions, so flipping it makes `not(can('order:internal'))` read as *allow
|
|
215
|
+
* everyone who is not internal, anonymous callers first*. That is how a "public unless internal"
|
|
216
|
+
* route becomes a public internal route: the mistake is invisible in `and(can(…), not(can(…)))`
|
|
217
|
+
* because the first clause carries the authentication, and it ships the moment someone simplifies.
|
|
218
|
+
*/
|
|
193
219
|
export const not = <I, R = unknown>(policy: Policy<I, R>): Policy<I, R> =>
|
|
194
220
|
combined('not', `not(${policy.label})`, [policy], (args, recorder, depth) => {
|
|
195
221
|
const decision = policy.run(args, recorder, depth);
|
|
196
|
-
|
|
222
|
+
if (decision.allowed) return denied(`not(${policy.label}) — inner clause allowed`);
|
|
223
|
+
if (decision.code === 'X_UNAUTHENTICATED') return decision;
|
|
224
|
+
return ALLOWED;
|
|
197
225
|
});
|
package/src/roles.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Roles are sugar over permissions: a role grants a set, and may inherit others.
|
|
2
2
|
// Everything is expanded to a flat permission set before any policy runs, so the
|
|
3
3
|
// evaluator never has to reason about hierarchy — and a cycle is caught here, once.
|
|
4
|
+
// The per-actor flattening and its memo live in `grant-index.ts`; this file owns the map.
|
|
4
5
|
import type { Actor as CoreActor } from '@ultimat3/core';
|
|
5
|
-
import {
|
|
6
|
+
import { roleRedefined } from './errors';
|
|
7
|
+
import { resourceOf } from './permissions';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The fields policy evaluation reads off an actor. `Actor` itself is core's; these
|
|
@@ -27,17 +29,81 @@ export interface RoleDef {
|
|
|
27
29
|
export type RoleMap = Readonly<Record<string, RoleDef>>;
|
|
28
30
|
|
|
29
31
|
let roleMap: RoleMap = {};
|
|
32
|
+
let sites: Readonly<Record<string, string>> = {};
|
|
33
|
+
let generation = 0;
|
|
30
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Bumped by every write to the map. `grant-index.ts` memoises a flattened grant set against
|
|
37
|
+
* it, the same shape `@ultimat3/entity`'s `relationMap()` uses against `registryGeneration()`:
|
|
38
|
+
* a memo that cannot be invalidated by a later `defineRoles()` is a stale-authz bug.
|
|
39
|
+
*/
|
|
40
|
+
export const roleMapGeneration = (): number => generation;
|
|
41
|
+
|
|
42
|
+
/** Frames inside this file — never the answer to "who declared this role?". */
|
|
43
|
+
const INTERNAL_FRAME = /declarationSite|defineRoles/;
|
|
44
|
+
|
|
45
|
+
const declarationSite = (): string => {
|
|
46
|
+
// Not a throw: the stack is the only place the declaring module's name exists at this point,
|
|
47
|
+
// and `X_ROLE_REDEFINED` is unactionable without both sides of the collision.
|
|
48
|
+
const stack = new Error().stack;
|
|
49
|
+
if (stack === undefined) return 'unknown site';
|
|
50
|
+
const frames = stack.split('\n').slice(1);
|
|
51
|
+
const frame = frames.find((line) => !INTERNAL_FRAME.test(line)) ?? frames[0] ?? '';
|
|
52
|
+
return frame.trim() === '' ? 'unknown site' : frame.trim();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const sameList = (left: readonly string[], right: readonly string[]): boolean => {
|
|
56
|
+
if (left.length !== right.length) return false;
|
|
57
|
+
const sortedRight = [...right].sort();
|
|
58
|
+
return [...left].sort().every((value, index) => value === sortedRight[index]);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A re-declaration of an identical role is a no-op, not a collision. That is what keeps the
|
|
63
|
+
* `defineRoles({ ...roleDefinitions(), … })` spelling both tracked apps already use legal:
|
|
64
|
+
* the spread hands every earlier role straight back.
|
|
65
|
+
*/
|
|
66
|
+
const sameDefinition = (left: RoleDef, right: RoleDef): boolean =>
|
|
67
|
+
left === right ||
|
|
68
|
+
(left.description === right.description &&
|
|
69
|
+
sameList(left.grants, right.grants) &&
|
|
70
|
+
sameList(left.inherits ?? [], right.inherits ?? []));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* MERGES into the app's one role map, and refuses a role two modules define differently.
|
|
74
|
+
*
|
|
75
|
+
* It used to replace, which made the map import-order-dependent: a second `defineRoles()` in a
|
|
76
|
+
* new feature folder silently deleted the first module's roles, every `can()` still typechecked,
|
|
77
|
+
* and every request 403'd on whichever bundler ordering CI happened to pick.
|
|
78
|
+
*/
|
|
31
79
|
export const defineRoles = <const M extends RoleMap>(map: M): M => {
|
|
32
|
-
|
|
80
|
+
const site = declarationSite();
|
|
81
|
+
const merged: Record<string, RoleDef> = { ...roleMap };
|
|
82
|
+
const nextSites: Record<string, string> = { ...sites };
|
|
83
|
+
for (const [role, definition] of Object.entries(map)) {
|
|
84
|
+
const existing = merged[role];
|
|
85
|
+
if (existing !== undefined && !sameDefinition(existing, definition)) {
|
|
86
|
+
throw roleRedefined(role, sites[role] ?? 'unknown site', site);
|
|
87
|
+
}
|
|
88
|
+
merged[role] = definition;
|
|
89
|
+
nextSites[role] = nextSites[role] ?? site;
|
|
90
|
+
}
|
|
91
|
+
roleMap = merged;
|
|
92
|
+
sites = nextSites;
|
|
93
|
+
generation += 1;
|
|
33
94
|
return map;
|
|
34
95
|
};
|
|
35
96
|
|
|
36
97
|
export const roleDefinitions = (): RoleMap => roleMap;
|
|
37
98
|
|
|
99
|
+
/** Where each role was first declared. Read by `X_ROLE_REDEFINED`, and by nothing else. */
|
|
100
|
+
export const roleDeclarationSites = (): Readonly<Record<string, string>> => sites;
|
|
101
|
+
|
|
38
102
|
/** Test seam. */
|
|
39
103
|
export const clearRoles = (): void => {
|
|
40
104
|
roleMap = {};
|
|
105
|
+
sites = {};
|
|
106
|
+
generation += 1;
|
|
41
107
|
};
|
|
42
108
|
|
|
43
109
|
/**
|
|
@@ -69,22 +135,6 @@ export const grantMatches = (grant: string, wanted: string): boolean => {
|
|
|
69
135
|
return false;
|
|
70
136
|
};
|
|
71
137
|
|
|
72
|
-
export const actorPermissions = (
|
|
73
|
-
actor: Actor | null,
|
|
74
|
-
map: RoleMap = roleMap,
|
|
75
|
-
): readonly string[] => {
|
|
76
|
-
if (actor === null) return [];
|
|
77
|
-
const direct = actor.permissions ?? [];
|
|
78
|
-
const fromRoles = expandRoles(actor.roles ?? [], map);
|
|
79
|
-
return [...new Set([...direct, ...fromRoles])].sort();
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
export const actorHas = (
|
|
83
|
-
actor: Actor | null,
|
|
84
|
-
permission: Permission,
|
|
85
|
-
map: RoleMap = roleMap,
|
|
86
|
-
): boolean => actorPermissions(actor, map).some((grant) => grantMatches(grant, permission));
|
|
87
|
-
|
|
88
138
|
/** For the `/_x` dashboard: which roles would satisfy a permission. */
|
|
89
139
|
export const rolesGranting = (permission: string, map: RoleMap = roleMap): readonly string[] =>
|
|
90
140
|
Object.keys(map)
|
package/src/surfaces.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// allowed. Adding a fifth surface means adding an adapter HERE and nothing else — no
|
|
4
4
|
// new policy model, no second authz path, no per-surface exceptions.
|
|
5
5
|
//
|
|
6
|
+
// An adapter names its surface and does nothing else with it: the decision log is emitted
|
|
7
|
+
// inside `evaluate()`, so the fifth adapter inherits it instead of having to remember it.
|
|
8
|
+
//
|
|
6
9
|
// The shapes are declared structurally rather than imported: `@ultimat3/http` is a
|
|
7
10
|
// sibling tier, and jobs/realtime/mcp are higher tiers that import this package.
|
|
8
11
|
|
|
@@ -55,7 +58,7 @@ export const enforceHttp = <I, R = unknown>(
|
|
|
55
58
|
policy: Policy<I, R>,
|
|
56
59
|
args: EvaluateArgs<I, R>,
|
|
57
60
|
): HttpDenial | undefined => {
|
|
58
|
-
const evaluation = evaluate(policy, args);
|
|
61
|
+
const evaluation = evaluate(policy, args, { surface: 'http' });
|
|
59
62
|
if (evaluation.allowed) return undefined;
|
|
60
63
|
return {
|
|
61
64
|
surface: 'http',
|
|
@@ -73,7 +76,7 @@ export const enforceLive = <I, R = unknown>(
|
|
|
73
76
|
policy: Policy<I, R>,
|
|
74
77
|
args: EvaluateArgs<I, R>,
|
|
75
78
|
): LiveDenial | undefined => {
|
|
76
|
-
const evaluation = evaluate(policy, args);
|
|
79
|
+
const evaluation = evaluate(policy, args, { surface: 'live' });
|
|
77
80
|
if (evaluation.allowed) return undefined;
|
|
78
81
|
return { surface: 'live', close: 4403, code: code(evaluation), reason: reason(evaluation) };
|
|
79
82
|
};
|
|
@@ -82,7 +85,7 @@ export const enforceJob = <I, R = unknown>(
|
|
|
82
85
|
policy: Policy<I, R>,
|
|
83
86
|
args: EvaluateArgs<I, R>,
|
|
84
87
|
): JobDenial | undefined => {
|
|
85
|
-
const evaluation = evaluate(policy, args);
|
|
88
|
+
const evaluation = evaluate(policy, args, { surface: 'job' });
|
|
86
89
|
if (evaluation.allowed) return undefined;
|
|
87
90
|
return {
|
|
88
91
|
surface: 'job',
|
|
@@ -97,7 +100,7 @@ export const enforceMcp = <I, R = unknown>(
|
|
|
97
100
|
policy: Policy<I, R>,
|
|
98
101
|
args: EvaluateArgs<I, R>,
|
|
99
102
|
): McpDenial | undefined => {
|
|
100
|
-
const evaluation = evaluate(policy, args);
|
|
103
|
+
const evaluation = evaluate(policy, args, { surface: 'mcp' });
|
|
101
104
|
if (evaluation.allowed) return undefined;
|
|
102
105
|
return {
|
|
103
106
|
surface: 'mcp',
|
package/src/test-kit.ts
CHANGED
|
@@ -39,12 +39,18 @@ export const policyMatrix = <I, R = unknown>(
|
|
|
39
39
|
const rows = args.actors.map((entry): MatrixRow => {
|
|
40
40
|
// Every field but the actor is forwarded verbatim: a matrix that dropped `row` would
|
|
41
41
|
// report a row rule as denying everyone, and the table would lie.
|
|
42
|
-
const evaluation = evaluate(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
const evaluation = evaluate(
|
|
43
|
+
policy,
|
|
44
|
+
{
|
|
45
|
+
input: args.input,
|
|
46
|
+
actor: entry.actor,
|
|
47
|
+
...(args.row === undefined ? {} : { row: args.row }),
|
|
48
|
+
...(args.ctx === undefined ? {} : { ctx: args.ctx }),
|
|
49
|
+
},
|
|
50
|
+
// The `deciding` column IS the matrix; a production default that skips the trace would
|
|
51
|
+
// blank it, and `x policy explain` renders this table.
|
|
52
|
+
{ trace: true },
|
|
53
|
+
);
|
|
48
54
|
return {
|
|
49
55
|
actor: entry.name,
|
|
50
56
|
allowed: evaluation.allowed,
|