@ultimat3/policy 22.10.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "22.10.0",
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.10.0"
34
+ "@ultimat3/core": "22.11.0"
35
35
  }
36
36
  }
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
- return {
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
- if (args.actor === null) {
111
- return record(recorder, this, depth, denied(`no actor for ${label}`, 'X_UNAUTHENTICATED'));
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
- kind: 'allow',
125
- label,
126
- permissions: [],
127
- children: [],
128
- run(_args, recorder, depth = 0) {
129
- return record(recorder, this, depth, ALLOWED);
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
- kind: 'deny',
138
- label: `deny(${reason})`,
139
- permissions: [],
140
- children: [],
141
- run(_args, recorder, depth = 0) {
142
- return record(recorder, this, depth, denied(reason, code));
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
- ): Policy<I, R> => ({
170
- kind,
171
- label,
172
- permissions: flatten(children),
173
- children,
174
- run(args, recorder, depth = 0) {
175
- return record(recorder, this, depth, decide(args, recorder, depth + 1));
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
- combined('not', `not(${policy.label})`, [policy], (args, recorder, depth) => {
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);
@@ -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
+ };