@cynodia/axiom-core 0.15.0-alpha.2 → 0.15.0-alpha.3

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.
@@ -21,6 +21,17 @@
21
21
  * equivalent to an inline `allow` over `PRINCIPAL`; `ReadPolicyDef` (spec10) remains the
22
22
  * row-level read mechanism, unified into query authorization by spec15 Phase D.
23
23
  *
24
+ * **spec15pt3 — one security-absence-aware evaluator for *every* authorization expression.**
25
+ * `AuthorizationPolicyDef.allow` and the legacy `ActionDef.authorization` expression share
26
+ * {@link evaluateAuthorizationExpression}: a `field(ref(PRINCIPAL), …)` / `field(ref(RESOURCE),
27
+ * …)` read of a key the scope object does not carry is **security-scope absence**, not an
28
+ * ordinary `undefined`, and `neq` / `not` / `or` / any comparison whose truth would depend on
29
+ * that absence is *not satisfied* — so a missing principal attribute can never manufacture
30
+ * authority through either surface (spec15pt3 §5, §7, §32). The two modes differ only in
31
+ * their *final interpretation* (a policy allows on exactly `true`; legacy keeps its historical
32
+ * truthiness), which `decideAuthorization` applies downstream — never in how absence, boolean
33
+ * composition or errors propagate.
34
+ *
24
35
  * This module is portable plain data + `Expression` trees; it carries no enforcement (that
25
36
  * is Phases C–F). The accessors are **total over any input** so a hand-tampered policy fails
26
37
  * closed with a structured diagnostic, never a native exception (spec15 §37).
@@ -122,6 +133,10 @@ export interface AuthorizationCheckInput {
122
133
  /**
123
134
  * The legacy `ActionDef.authorization` expression outcome, when that expression is present.
124
135
  * Its historical truthiness rule is kept: a non-empty array or any truthy value allows.
136
+ * spec15pt3 — this part MUST be produced by {@link evaluateAuthorizationExpression} (mode
137
+ * `'legacy-action'`), so `{ ok: true, value: false }` for a decision that depended on a
138
+ * missing security-scope field: absence reaches this combiner as a plain DENY, never as a
139
+ * truthy value.
125
140
  */
126
141
  legacy?: AuthorizationCheckPart;
127
142
  }
@@ -152,21 +167,57 @@ export interface AuthorizationPolicyScope {
152
167
  operation: string;
153
168
  }
154
169
  /**
155
- * spec15pt2 F1 — the **canonical** three-valued `AuthorizationPolicyDef.allow` evaluator,
156
- * used by every policy-bearing surface (spec15pt2 §33). Returns the `{ ok, value }` shape
157
- * `decideAuthorization` already consumes:
170
+ * Which authorization surface an expression is evaluated for (spec15pt3 §32). Both modes
171
+ * share {@link evaluateAuthorizationExpression}'s security-absence, boolean-composition and
172
+ * error propagation; they differ only downstream, in `decideAuthorization` — a `'policy'`
173
+ * result allows on exactly `true`, a `'legacy-action'` result keeps the historical
174
+ * `ActionDef.authorization` truthiness rule.
175
+ */
176
+ export type AuthorizationEvaluationMode = 'policy' | 'legacy-action';
177
+ /**
178
+ * The evaluation context for {@link evaluateAuthorizationExpression}. `'policy'` mode uses the
179
+ * closed `{ principal, resource, operation }` scope only. `'legacy-action'` mode additionally
180
+ * honours the historical `ActionDef.authorization` scope, which could also read an ordinary
181
+ * `StateDef`: {@link resolveExternalRef} resolves such a `ref` through the caller's ordinary
182
+ * evaluator, and an id it cannot resolve fails **closed** (never silently allows), exactly as
183
+ * a throwing `runtime.evaluate` did pre-pt3.
184
+ */
185
+ export interface AuthorizationExpressionContext {
186
+ principal: Record<string, unknown> | null | undefined;
187
+ resource?: Record<string, unknown> | null | undefined;
188
+ operation?: string;
189
+ resolveExternalRef?: (refId: string) => {
190
+ found: boolean;
191
+ value?: unknown;
192
+ };
193
+ }
194
+ /**
195
+ * spec15pt3 §32 — the **one** canonical security-absence-aware authorization-expression
196
+ * evaluator, shared by `AuthorizationPolicyDef.allow` (`mode: 'policy'`) and the legacy
197
+ * `ActionDef.authorization` expression (`mode: 'legacy-action'`). Returns the `{ ok, value }`
198
+ * shape `decideAuthorization` consumes:
158
199
  *
159
- * - `allow` reduces to **exactly `true`** (with every required security input present) ⇒
160
- * `{ ok: true, value: true }` — the only ALLOW;
161
- * - `allow` reduces to any other concrete value ⇒ `{ ok: true, value: false }`;
162
- * - the truth of `allow` depends on a **missing PRINCIPAL / RESOURCE field**
163
- * (`AbsentSecurityValue`) ⇒ `{ ok: true, value: false }` — absence never creates authority
164
- * through `eq` / `neq` / `not` / `or` (spec15pt2 §8-§12);
165
- * - an evaluation error (malformed node reaching the runtime, unsupported builtin) ⇒
166
- * `{ ok: false }`.
200
+ * - the expression reduces to a concrete value ⇒ `{ ok: true, value }` — for `'policy'` only
201
+ * an exact `true` is ALLOW; for `'legacy-action'` the historical truthiness rule (a truthy
202
+ * value / non-empty array) applies, both in `decideAuthorization`;
203
+ * - its truth depends on a **missing PRINCIPAL / RESOURCE field** ⇒ `{ ok: true, value: false }`
204
+ * — absence never creates authority through `eq` / `neq` / `not` / `lt` / `contains` /
205
+ * `or` / any composition (spec15pt2 §8-§12, spec15pt3 §11-§16, §79-§82);
206
+ * - an evaluation error (malformed node reaching the runtime, unsupported builtin, an
207
+ * unresolvable legacy `ref`) ⇒ `{ ok: false }` — fail closed, and the error keeps its
208
+ * provenance through `not` / `or` so it cannot be negated back to ALLOW (spec15pt3 §84).
167
209
  *
168
210
  * A constant `literal(true)` is `{ ok: true, value: true }` regardless of principal — an
169
- * explicitly public policy still admits an anonymous caller (spec15pt2 §13, §14).
211
+ * explicitly public rule still admits an anonymous caller; the invariant is "missing
212
+ * referenced security fields cannot create authority", not "anonymous is forbidden"
213
+ * (spec15pt2 §13, §14; spec15pt3 §17). A `literal` nullish value stays a concrete value,
214
+ * never security absence (spec15pt3 §58).
215
+ */
216
+ export declare function evaluateAuthorizationExpression(expression: unknown, context: AuthorizationExpressionContext, mode: AuthorizationEvaluationMode): AuthorizationCheckPart;
217
+ /**
218
+ * spec15pt2 F1 — the three-valued `AuthorizationPolicyDef.allow` evaluator. A thin, stable
219
+ * wrapper over {@link evaluateAuthorizationExpression} in `'policy'` mode, kept for every
220
+ * existing policy-bearing call site (spec15pt2 §33).
170
221
  */
171
222
  export declare function evaluateAuthorizationPolicyAllow(allow: unknown, scope: AuthorizationPolicyScope): AuthorizationCheckPart;
172
223
  /**
@@ -21,6 +21,17 @@
21
21
  * equivalent to an inline `allow` over `PRINCIPAL`; `ReadPolicyDef` (spec10) remains the
22
22
  * row-level read mechanism, unified into query authorization by spec15 Phase D.
23
23
  *
24
+ * **spec15pt3 — one security-absence-aware evaluator for *every* authorization expression.**
25
+ * `AuthorizationPolicyDef.allow` and the legacy `ActionDef.authorization` expression share
26
+ * {@link evaluateAuthorizationExpression}: a `field(ref(PRINCIPAL), …)` / `field(ref(RESOURCE),
27
+ * …)` read of a key the scope object does not carry is **security-scope absence**, not an
28
+ * ordinary `undefined`, and `neq` / `not` / `or` / any comparison whose truth would depend on
29
+ * that absence is *not satisfied* — so a missing principal attribute can never manufacture
30
+ * authority through either surface (spec15pt3 §5, §7, §32). The two modes differ only in
31
+ * their *final interpretation* (a policy allows on exactly `true`; legacy keeps its historical
32
+ * truthiness), which `decideAuthorization` applies downstream — never in how absence, boolean
33
+ * composition or errors propagate.
34
+ *
24
35
  * This module is portable plain data + `Expression` trees; it carries no enforcement (that
25
36
  * is Phases C–F). The accessors are **total over any input** so a hand-tampered policy fails
26
37
  * closed with a structured diagnostic, never a native exception (spec15 §37).
@@ -377,6 +388,14 @@ function evalAuthz(expression, scope, seen) {
377
388
  return C(scope.resource ?? null);
378
389
  if (t === AUTHZ_OPERATION_SCOPE)
379
390
  return C(scope.operation);
391
+ // spec15pt3 — a legacy `ActionDef.authorization` expression's historical scope also
392
+ // reaches ordinary `StateDef` refs. Resolve them through the caller's ordinary
393
+ // evaluator; an unresolved ref fails closed (`E` ⇒ DENY), never silently allows. A
394
+ // policy expression's scope is closed, so this branch is unreachable in `'policy'` mode.
395
+ if (scope.mode === 'legacy-action' && scope.resolveExternalRef) {
396
+ const resolved = scope.resolveExternalRef(t);
397
+ return resolved.found ? C(resolved.value) : E;
398
+ }
380
399
  return E; // out of the closed scope — validation rejects it; be total
381
400
  }
382
401
  case 'field': {
@@ -541,26 +560,37 @@ function applyPolicyBuiltin(fn, args) {
541
560
  }
542
561
  }
543
562
  /**
544
- * spec15pt2 F1 — the **canonical** three-valued `AuthorizationPolicyDef.allow` evaluator,
545
- * used by every policy-bearing surface (spec15pt2 §33). Returns the `{ ok, value }` shape
546
- * `decideAuthorization` already consumes:
563
+ * spec15pt3 §32 — the **one** canonical security-absence-aware authorization-expression
564
+ * evaluator, shared by `AuthorizationPolicyDef.allow` (`mode: 'policy'`) and the legacy
565
+ * `ActionDef.authorization` expression (`mode: 'legacy-action'`). Returns the `{ ok, value }`
566
+ * shape `decideAuthorization` consumes:
547
567
  *
548
- * - `allow` reduces to **exactly `true`** (with every required security input present) ⇒
549
- * `{ ok: true, value: true }` — the only ALLOW;
550
- * - `allow` reduces to any other concrete value ⇒ `{ ok: true, value: false }`;
551
- * - the truth of `allow` depends on a **missing PRINCIPAL / RESOURCE field**
552
- * (`AbsentSecurityValue`) ⇒ `{ ok: true, value: false }` — absence never creates authority
553
- * through `eq` / `neq` / `not` / `or` (spec15pt2 §8-§12);
554
- * - an evaluation error (malformed node reaching the runtime, unsupported builtin) ⇒
555
- * `{ ok: false }`.
568
+ * - the expression reduces to a concrete value ⇒ `{ ok: true, value }` — for `'policy'` only
569
+ * an exact `true` is ALLOW; for `'legacy-action'` the historical truthiness rule (a truthy
570
+ * value / non-empty array) applies, both in `decideAuthorization`;
571
+ * - its truth depends on a **missing PRINCIPAL / RESOURCE field** ⇒ `{ ok: true, value: false }`
572
+ * — absence never creates authority through `eq` / `neq` / `not` / `lt` / `contains` /
573
+ * `or` / any composition (spec15pt2 §8-§12, spec15pt3 §11-§16, §79-§82);
574
+ * - an evaluation error (malformed node reaching the runtime, unsupported builtin, an
575
+ * unresolvable legacy `ref`) ⇒ `{ ok: false }` — fail closed, and the error keeps its
576
+ * provenance through `not` / `or` so it cannot be negated back to ALLOW (spec15pt3 §84).
556
577
  *
557
578
  * A constant `literal(true)` is `{ ok: true, value: true }` regardless of principal — an
558
- * explicitly public policy still admits an anonymous caller (spec15pt2 §13, §14).
579
+ * explicitly public rule still admits an anonymous caller; the invariant is "missing
580
+ * referenced security fields cannot create authority", not "anonymous is forbidden"
581
+ * (spec15pt2 §13, §14; spec15pt3 §17). A `literal` nullish value stays a concrete value,
582
+ * never security absence (spec15pt3 §58).
559
583
  */
560
- export function evaluateAuthorizationPolicyAllow(allow, scope) {
584
+ export function evaluateAuthorizationExpression(expression, context, mode) {
561
585
  let result;
562
586
  try {
563
- result = evalAuthz(allow, scope, new Set());
587
+ result = evalAuthz(expression, {
588
+ principal: context.principal ?? null,
589
+ resource: context.resource ?? null,
590
+ operation: context.operation ?? 'action.invoke',
591
+ mode,
592
+ ...(context.resolveExternalRef ? { resolveExternalRef: context.resolveExternalRef } : {}),
593
+ }, new Set());
564
594
  }
565
595
  catch {
566
596
  return { ok: false };
@@ -571,6 +601,14 @@ export function evaluateAuthorizationPolicyAllow(allow, scope) {
571
601
  return { ok: true, value: false };
572
602
  return { ok: true, value: result.v };
573
603
  }
604
+ /**
605
+ * spec15pt2 F1 — the three-valued `AuthorizationPolicyDef.allow` evaluator. A thin, stable
606
+ * wrapper over {@link evaluateAuthorizationExpression} in `'policy'` mode, kept for every
607
+ * existing policy-bearing call site (spec15pt2 §33).
608
+ */
609
+ export function evaluateAuthorizationPolicyAllow(allow, scope) {
610
+ return evaluateAuthorizationExpression(allow, { principal: scope.principal, resource: scope.resource, operation: scope.operation }, 'policy');
611
+ }
574
612
  /**
575
613
  * The distinct policy ids a graph node references for authorization — for dependency
576
614
  * analysis and `validateGraph` reference resolution. Total over malformed input.
@@ -101,12 +101,15 @@ export interface AuthorityCompatibilityKey {
101
101
  serverContract: string;
102
102
  semanticFingerprint: string;
103
103
  /**
104
- * spec15pt2 §35 — the runtime authorization-evaluator semantics version. `0.15.0-alpha.1`
105
- * and `0.15.0-alpha.2` evaluate the *same* Server IR authorization policy differently
106
- * (absent-value safety, F1), yet the graph — and therefore `semanticFingerprint` — is
107
- * identical. This discriminator, present only when the IR carries authorization
108
- * vocabulary, keeps the two builds from silently co-participating in one authority domain.
109
- * Absent on a graph with no authorization policy (its evaluation is unchanged).
104
+ * spec15pt2 §35, spec15pt3 §37-§39 — the runtime authorization-evaluator semantics
105
+ * version. Successive builds evaluate the *same* Server IR authorization expression
106
+ * differently — `alpha.1` → `alpha.2` for `AuthorizationPolicyDef.allow` (absent-value
107
+ * safety, F1), `alpha.2` → `alpha.3` for the legacy `ActionDef.authorization` expression
108
+ * (F1-legacy) — yet the graph, and therefore `semanticFingerprint`, is identical. This
109
+ * discriminator, present whenever the IR carries an authorization *decision* (a policy
110
+ * reference or a legacy `authorization` expression), keeps builds that disagree from
111
+ * silently co-participating in one authority domain. Absent on a graph with no
112
+ * authorization decision (its evaluation is unchanged across every build).
110
113
  */
111
114
  authorizationRuntime?: string;
112
115
  }
@@ -141,6 +141,18 @@ export declare function usesAuthorizationVocabulary(ir: {
141
141
  instanceAccessPolicy?: unknown;
142
142
  }[];
143
143
  }): boolean;
144
+ /**
145
+ * Whether a document carries any legacy `ActionDef.authorization` expression (spec15pt3
146
+ * §39). This is not 0.15 *vocabulary* — it predates the milestone — but its runtime meaning
147
+ * changed in `0.15.0-alpha.3` (security-absence-aware evaluation, F1-legacy), so a mixed
148
+ * `alpha.2` / `alpha.3` cluster over a graph that can exercise it must fail closed. Total
149
+ * over a tampered IR.
150
+ */
151
+ export declare function usesLegacyActionAuthorization(ir: {
152
+ actions?: Record<string, {
153
+ authorization?: unknown;
154
+ }>;
155
+ }): boolean;
144
156
  /**
145
157
  * Authorization vocabulary whose *enforcement* has not shipped yet — the admission gate a
146
158
  * build uses to fail closed rather than run a declared policy as a silent no-op (spec4 §4,
package/dist/server-ir.js CHANGED
@@ -177,6 +177,21 @@ export function usesAuthorizationVocabulary(ir) {
177
177
  }
178
178
  return false;
179
179
  }
180
+ /**
181
+ * Whether a document carries any legacy `ActionDef.authorization` expression (spec15pt3
182
+ * §39). This is not 0.15 *vocabulary* — it predates the milestone — but its runtime meaning
183
+ * changed in `0.15.0-alpha.3` (security-absence-aware evaluation, F1-legacy), so a mixed
184
+ * `alpha.2` / `alpha.3` cluster over a graph that can exercise it must fail closed. Total
185
+ * over a tampered IR.
186
+ */
187
+ export function usesLegacyActionAuthorization(ir) {
188
+ for (const action of Object.values(ir.actions ?? {})) {
189
+ if (action && typeof action === 'object' && action.authorization !== undefined) {
190
+ return true;
191
+ }
192
+ }
193
+ return false;
194
+ }
180
195
  /**
181
196
  * Authorization vocabulary whose *enforcement* has not shipped yet — the admission gate a
182
197
  * build uses to fail closed rather than run a declared policy as a silent no-op (spec4 §4,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.15.0-alpha.2",
3
+ "version": "0.15.0-alpha.3",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",