@ultimat3/policy 22.9.1 → 22.11.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/package.json +2 -2
- package/src/errors.ts +5 -1
- package/src/index.ts +2 -0
- package/src/policy.ts +104 -38
- package/src/pre-input-brand.ts +41 -0
- package/src/pre-input.ts +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/policy",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.11.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": "22.
|
|
34
|
+
"@ultimat3/core": "22.11.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/errors.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// declaration site that cannot express the requirement in a type — a config-driven route table,
|
|
5
5
|
// a policy resolved by name — and `policyMissing()` is how such a site says it.
|
|
6
6
|
import {
|
|
7
|
+
deniedCallerFix,
|
|
7
8
|
type ErrorRetry,
|
|
8
9
|
nearestName,
|
|
9
10
|
registerErrorCodes,
|
|
@@ -75,11 +76,12 @@ registerErrorRetry(POLICY_ERROR_RETRY);
|
|
|
75
76
|
export class PolicyError extends UltimateError {
|
|
76
77
|
override readonly name = 'PolicyError';
|
|
77
78
|
|
|
78
|
-
constructor(init: { code: PolicyErrorCode; cause: string; fix: string }) {
|
|
79
|
+
constructor(init: { code: PolicyErrorCode; cause: string; fix: string; callerFix?: string }) {
|
|
79
80
|
super({
|
|
80
81
|
code: init.code,
|
|
81
82
|
cause: init.cause,
|
|
82
83
|
fix: init.fix,
|
|
84
|
+
...(init.callerFix === undefined ? {} : { callerFix: init.callerFix }),
|
|
83
85
|
});
|
|
84
86
|
}
|
|
85
87
|
}
|
|
@@ -105,6 +107,8 @@ export const forbidden = (label: string, reason: string): PolicyError =>
|
|
|
105
107
|
fix: BARE_PERMISSION.test(label)
|
|
106
108
|
? `x policy explain ${label} --json # shows which clause decided and why`
|
|
107
109
|
: `x policy list --json # then: x policy explain <permission> --json for the clause that decided`,
|
|
110
|
+
// The remote caller's half: it holds a credential, not the policy file.
|
|
111
|
+
callerFix: deniedCallerFix(BARE_PERMISSION.test(label) ? label : undefined),
|
|
108
112
|
});
|
|
109
113
|
|
|
110
114
|
export const policyMissing = (subject: string): PolicyError =>
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,8 @@ export type {
|
|
|
57
57
|
} from './policy';
|
|
58
58
|
export { ALLOWED, allow, and, can, denied, deny, not, or, policyPermissions } from './policy';
|
|
59
59
|
export { admitsAnonymous } from './policy-anonymous';
|
|
60
|
+
export type { PreInputArgs } from './pre-input';
|
|
61
|
+
export { decideBeforeInput, enforceBeforeInput } from './pre-input';
|
|
60
62
|
export type { Actor, RoleDef, RoleMap } from './roles';
|
|
61
63
|
export {
|
|
62
64
|
clearRoles,
|
package/src/policy.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Ctx } from '@ultimat3/core';
|
|
|
5
5
|
import { emptyClauseList } from './errors';
|
|
6
6
|
import { actorHas } from './grant-index';
|
|
7
7
|
import { assertPermission, type KnownPermission, type Permission } from './permissions';
|
|
8
|
+
import { markPreInput, type PreInputRun, preInputRun } from './pre-input-brand';
|
|
8
9
|
import type { Actor } from './roles';
|
|
9
10
|
|
|
10
11
|
export type PolicyDecision =
|
|
@@ -101,47 +102,65 @@ export const can = <I = unknown, R = unknown>(
|
|
|
101
102
|
): Policy<I, R> => {
|
|
102
103
|
assertPermission(permission);
|
|
103
104
|
const label = permission;
|
|
104
|
-
|
|
105
|
+
// The actor half of the rule, on its own: it reads nothing the caller sent, which is what lets
|
|
106
|
+
// the before-input gate (`pre-input.ts`) refuse a caller who may never call this before their
|
|
107
|
+
// payload is parsed — and a 403 then says nothing about the input schema.
|
|
108
|
+
const gate = (actor: Actor | null): PolicyDecision | undefined => {
|
|
109
|
+
if (actor === null) return denied(`no actor for ${label}`, 'X_UNAUTHENTICATED');
|
|
110
|
+
if (!actorHas(actor, permission as Permission)) return denied(`actor lacks ${label}`);
|
|
111
|
+
return undefined;
|
|
112
|
+
};
|
|
113
|
+
const policy: Policy<I, R> = {
|
|
105
114
|
kind: 'permission',
|
|
106
115
|
label,
|
|
107
116
|
permissions: [permission as Permission],
|
|
108
117
|
children: [],
|
|
109
118
|
run(args, recorder, depth = 0) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
if (!actorHas(args.actor, permission as Permission)) {
|
|
114
|
-
return record(recorder, this, depth, denied(`actor lacks ${label}`));
|
|
115
|
-
}
|
|
119
|
+
const refused = gate(args.actor);
|
|
120
|
+
if (refused !== undefined) return record(recorder, this, depth, refused);
|
|
116
121
|
if (predicate === undefined) return record(recorder, this, depth, ALLOWED);
|
|
117
122
|
return record(recorder, this, depth, asDecision(predicate(args), label));
|
|
118
123
|
},
|
|
119
124
|
};
|
|
125
|
+
return markPreInput(policy, (args, recorder, depth) => {
|
|
126
|
+
const refused = gate(args.actor);
|
|
127
|
+
if (refused !== undefined) return record(recorder, policy, depth, refused);
|
|
128
|
+
// A predicate reads the input (or the row), so with one the clause is undecided until then.
|
|
129
|
+
return predicate === undefined ? record(recorder, policy, depth, ALLOWED) : undefined;
|
|
130
|
+
});
|
|
120
131
|
};
|
|
121
132
|
|
|
122
133
|
/** Explicitly public. Saying so is required; forgetting a policy is a build error. */
|
|
123
|
-
export const allow = <I = unknown, R = unknown>(label = 'allow'): Policy<I, R> =>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
134
|
+
export const allow = <I = unknown, R = unknown>(label = 'allow'): Policy<I, R> => {
|
|
135
|
+
const policy: Policy<I, R> = {
|
|
136
|
+
kind: 'allow',
|
|
137
|
+
label,
|
|
138
|
+
permissions: [],
|
|
139
|
+
children: [],
|
|
140
|
+
run(_args, recorder, depth = 0) {
|
|
141
|
+
return record(recorder, this, depth, ALLOWED);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
return markPreInput(policy, (_args, recorder, depth) => record(recorder, policy, depth, ALLOWED));
|
|
145
|
+
};
|
|
132
146
|
|
|
133
147
|
export const deny = <I = unknown, R = unknown>(
|
|
134
148
|
reason: string,
|
|
135
149
|
code = 'X_FORBIDDEN',
|
|
136
|
-
): Policy<I, R> =>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
150
|
+
): Policy<I, R> => {
|
|
151
|
+
const policy: Policy<I, R> = {
|
|
152
|
+
kind: 'deny',
|
|
153
|
+
label: `deny(${reason})`,
|
|
154
|
+
permissions: [],
|
|
155
|
+
children: [],
|
|
156
|
+
run(_args, recorder, depth = 0) {
|
|
157
|
+
return record(recorder, this, depth, denied(reason, code));
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
return markPreInput(policy, (_args, recorder, depth) =>
|
|
161
|
+
record(recorder, policy, depth, denied(reason, code)),
|
|
162
|
+
);
|
|
163
|
+
};
|
|
145
164
|
|
|
146
165
|
/**
|
|
147
166
|
* Every permission a policy tree references, deduped and sorted. This is the list a compliance
|
|
@@ -166,15 +185,22 @@ const combined = <I, R>(
|
|
|
166
185
|
label: string,
|
|
167
186
|
children: readonly Policy<I, R>[],
|
|
168
187
|
decide: (args: PolicyArgs<I, R>, recorder: Recorder | undefined, depth: number) => PolicyDecision,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
188
|
+
pre: PreInputRun,
|
|
189
|
+
): Policy<I, R> => {
|
|
190
|
+
const policy: Policy<I, R> = {
|
|
191
|
+
kind,
|
|
192
|
+
label,
|
|
193
|
+
permissions: flatten(children),
|
|
194
|
+
children,
|
|
195
|
+
run(args, recorder, depth = 0) {
|
|
196
|
+
return record(recorder, this, depth, decide(args, recorder, depth + 1));
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
return markPreInput(policy, (args, recorder, depth) => {
|
|
200
|
+
const decision = pre(args, recorder, depth + 1);
|
|
201
|
+
return decision === undefined ? undefined : record(recorder, policy, depth, decision);
|
|
202
|
+
});
|
|
203
|
+
};
|
|
178
204
|
|
|
179
205
|
/**
|
|
180
206
|
* First denial wins, and its reason is the reason — short-circuit, left to right.
|
|
@@ -198,6 +224,17 @@ export const and = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Polic
|
|
|
198
224
|
}
|
|
199
225
|
return ALLOWED;
|
|
200
226
|
},
|
|
227
|
+
// Any clause that denies whatever the input is denies the conjunction whatever the input is —
|
|
228
|
+
// even past an undecided clause to its left, whose own verdict could only add a denial.
|
|
229
|
+
(args, recorder, depth) => {
|
|
230
|
+
let undecided = false;
|
|
231
|
+
for (const policy of policies) {
|
|
232
|
+
const decision = preInputRun(policy, args, recorder, depth);
|
|
233
|
+
if (decision === undefined) undecided = true;
|
|
234
|
+
else if (!decision.allowed) return decision;
|
|
235
|
+
}
|
|
236
|
+
return undecided ? undefined : ALLOWED;
|
|
237
|
+
},
|
|
201
238
|
);
|
|
202
239
|
};
|
|
203
240
|
|
|
@@ -235,6 +272,25 @@ export const or = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy
|
|
|
235
272
|
}
|
|
236
273
|
return unauthenticated ?? last;
|
|
237
274
|
},
|
|
275
|
+
// Decided only when some clause allows, or every clause denies; one undecided clause could
|
|
276
|
+
// still allow, so the disjunction waits for the input.
|
|
277
|
+
(args, recorder, depth) => {
|
|
278
|
+
let last: PolicyDecision = denied('no clause allowed this actor');
|
|
279
|
+
let unauthenticated: PolicyDecision | undefined;
|
|
280
|
+
let undecided = false;
|
|
281
|
+
for (const policy of policies) {
|
|
282
|
+
const decision = preInputRun(policy, args, recorder, depth);
|
|
283
|
+
if (decision === undefined) {
|
|
284
|
+
undecided = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (decision.allowed) return ALLOWED;
|
|
288
|
+
if (decision.code === 'X_UNAUTHENTICATED') unauthenticated = decision;
|
|
289
|
+
last = decision;
|
|
290
|
+
}
|
|
291
|
+
if (undecided) return undefined;
|
|
292
|
+
return unauthenticated ?? last;
|
|
293
|
+
},
|
|
238
294
|
);
|
|
239
295
|
};
|
|
240
296
|
|
|
@@ -247,10 +303,20 @@ export const or = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy
|
|
|
247
303
|
* route becomes a public internal route: the mistake is invisible in `and(can(…), not(can(…)))`
|
|
248
304
|
* because the first clause carries the authentication, and it ships the moment someone simplifies.
|
|
249
305
|
*/
|
|
250
|
-
export const not = <I, R = unknown>(policy: Policy<I, R>): Policy<I, R> =>
|
|
251
|
-
|
|
252
|
-
const decision = policy.run(args, recorder, depth);
|
|
306
|
+
export const not = <I, R = unknown>(policy: Policy<I, R>): Policy<I, R> => {
|
|
307
|
+
const invert = (decision: PolicyDecision): PolicyDecision => {
|
|
253
308
|
if (decision.allowed) return denied(`not(${policy.label}) — inner clause allowed`);
|
|
254
309
|
if (decision.code === 'X_UNAUTHENTICATED') return decision;
|
|
255
310
|
return ALLOWED;
|
|
256
|
-
}
|
|
311
|
+
};
|
|
312
|
+
return combined(
|
|
313
|
+
'not',
|
|
314
|
+
`not(${policy.label})`,
|
|
315
|
+
[policy],
|
|
316
|
+
(args, recorder, depth) => invert(policy.run(args, recorder, depth)),
|
|
317
|
+
(args, recorder, depth) => {
|
|
318
|
+
const decision = preInputRun(policy, args, recorder, depth);
|
|
319
|
+
return decision === undefined ? undefined : invert(decision);
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// What each built policy can decide BEFORE its input is parsed, stored beside the policy rather
|
|
2
|
+
// than on it. A WeakMap keyed by the objects `policy.ts` builds, so a foreign `Policy` — one an
|
|
3
|
+
// app assembled by hand, with a `run` this package never saw — has no entry and is answered
|
|
4
|
+
// "depends on input": the before-input gate can only ever DEFER to the full evaluation for it,
|
|
5
|
+
// never decide on its behalf.
|
|
6
|
+
import type { Ctx } from '@ultimat3/core';
|
|
7
|
+
import type { PolicyDecision, Recorder } from './policy';
|
|
8
|
+
import type { Actor } from './roles';
|
|
9
|
+
|
|
10
|
+
/** Everything a decision before the input parse may read: who is asking, and nothing they sent. */
|
|
11
|
+
export interface PreInputArgs {
|
|
12
|
+
readonly actor: Actor | null;
|
|
13
|
+
readonly ctx?: Ctx | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `undefined` is "this clause cannot be decided without the input" — a predicate would have to
|
|
18
|
+
* run. A decision is final: the full evaluation of the same clause over ANY input reaches the
|
|
19
|
+
* same `allowed`.
|
|
20
|
+
*/
|
|
21
|
+
export type PreInputRun = (
|
|
22
|
+
args: PreInputArgs,
|
|
23
|
+
recorder: Recorder | undefined,
|
|
24
|
+
depth: number,
|
|
25
|
+
) => PolicyDecision | undefined;
|
|
26
|
+
|
|
27
|
+
const PRE_INPUT = new WeakMap<object, PreInputRun>();
|
|
28
|
+
|
|
29
|
+
/** Called once per policy `policy.ts` builds. Never exported from `src/index.ts`. */
|
|
30
|
+
export const markPreInput = <P extends object>(policy: P, run: PreInputRun): P => {
|
|
31
|
+
PRE_INPUT.set(policy, run);
|
|
32
|
+
return policy;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The before-input decision of one clause; unbranded clauses depend on input by definition. */
|
|
36
|
+
export const preInputRun = (
|
|
37
|
+
policy: object,
|
|
38
|
+
args: PreInputArgs,
|
|
39
|
+
recorder: Recorder | undefined,
|
|
40
|
+
depth: number,
|
|
41
|
+
): PolicyDecision | undefined => PRE_INPUT.get(policy)?.(args, recorder, depth);
|
package/src/pre-input.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The before-input gate: the part of a policy that reads only WHO is asking, decided before the
|
|
2
|
+
// caller's payload is parsed. Without it a caller who may never call an operation learns its input
|
|
3
|
+
// schema from the 400 (`X_INPUT_INVALID` lists every field it wanted) instead of getting the 403
|
|
4
|
+
// the policy would give — a staff operation's shape, told to any customer who sends `{}`.
|
|
5
|
+
//
|
|
6
|
+
// Exact, not a heuristic: a clause is decided here only when no predicate would have to run
|
|
7
|
+
// (`can()` without one, `allow()`, `deny()`, and the combinators over them — plus `can(p, pred)`
|
|
8
|
+
// for an actor WITHOUT `p`, whose predicate never runs either). Everything else is "undecided",
|
|
9
|
+
// the surface parses the input, and the full `evaluate()` decides as it always did.
|
|
10
|
+
|
|
11
|
+
import { ALLOWED, type Policy, type PolicyDecision } from './policy';
|
|
12
|
+
import { type PreInputArgs, preInputRun } from './pre-input-brand';
|
|
13
|
+
import { enforce, type Surface, type SurfaceDenial } from './surfaces';
|
|
14
|
+
|
|
15
|
+
export type { PreInputArgs } from './pre-input-brand';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the policy decides for this actor whatever the input is; `undefined` when the answer
|
|
19
|
+
* depends on the input (or on a row). A policy this package did not build is always `undefined`.
|
|
20
|
+
*/
|
|
21
|
+
export const decideBeforeInput = <I, R>(
|
|
22
|
+
policy: Policy<I, R>,
|
|
23
|
+
args: PreInputArgs,
|
|
24
|
+
): PolicyDecision | undefined => preInputRun(policy, args, undefined, 0);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The surface adapter for the gate: a denial shaped exactly like `enforce()`'s for the same
|
|
28
|
+
* surface, or `undefined` — for an allowance AND for "undecided", because either way the full
|
|
29
|
+
* evaluation still runs after the parse. A denial goes through `enforce()` itself, so it is traced
|
|
30
|
+
* and reaches the `DecisionSink` once, like any other; a caller let through here emits nothing,
|
|
31
|
+
* which keeps one decision event per call.
|
|
32
|
+
*/
|
|
33
|
+
export const enforceBeforeInput = <I, R>(
|
|
34
|
+
surface: Surface,
|
|
35
|
+
policy: Policy<I, R>,
|
|
36
|
+
args: PreInputArgs,
|
|
37
|
+
): SurfaceDenial | undefined => {
|
|
38
|
+
const decision = decideBeforeInput(policy, args);
|
|
39
|
+
if (decision === undefined || decision.allowed) return undefined;
|
|
40
|
+
// The same tree, answering only what it can decide without input. `run` is re-entered by
|
|
41
|
+
// `evaluate()` with a recorder, so the trace names the clause that refused.
|
|
42
|
+
const projected: Policy<I, R> = {
|
|
43
|
+
kind: policy.kind,
|
|
44
|
+
label: policy.label,
|
|
45
|
+
permissions: policy.permissions,
|
|
46
|
+
children: policy.children,
|
|
47
|
+
run: (runArgs, recorder, depth = 0) =>
|
|
48
|
+
preInputRun(policy, { actor: runArgs.actor, ctx: runArgs.ctx }, recorder, depth) ?? ALLOWED,
|
|
49
|
+
};
|
|
50
|
+
return enforce(surface, projected, {
|
|
51
|
+
// Never read: every clause the projection answers ignores the input by construction.
|
|
52
|
+
input: undefined as I,
|
|
53
|
+
actor: args.actor,
|
|
54
|
+
...(args.ctx === undefined ? {} : { ctx: args.ctx }),
|
|
55
|
+
});
|
|
56
|
+
};
|