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

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.
@@ -86,12 +86,6 @@ export interface AuthorizationPolicyProblem {
86
86
  code: 'AUTHORIZATION_INVALID_POLICY' | 'AUTHORIZATION_INVALID_SCOPE' | 'AUTHORIZATION_NONDETERMINISTIC';
87
87
  message: string;
88
88
  }
89
- /**
90
- * A **total** structural + scope check on an `AuthorizationPolicyDef` value — for any input,
91
- * including a hand-tampered Server IR where the policy is the wrong shape entirely (spec15
92
- * §37). Cross-node reference resolution (does an `authorizationPolicy` id point at a real
93
- * policy) is `validateGraph`'s job, not this function's.
94
- */
95
89
  export declare function authorizationPolicyProblems(policy: unknown): AuthorizationPolicyProblem[];
96
90
  export interface AuthorizationPolicyDependencies {
97
91
  /** Field ids the policy's `allow` expression reads off `PRINCIPAL`. */
@@ -147,6 +141,34 @@ export interface AuthorizationCheckResult {
147
141
  * - otherwise ALLOW (`allowed`). Both, when present, must pass (conjunction).
148
142
  */
149
143
  export declare function decideAuthorization(input: AuthorizationCheckInput): AuthorizationCheckResult;
144
+ /**
145
+ * The closed scope an `AuthorizationPolicyDef.allow` expression is evaluated against. Each
146
+ * field is a plain record or `null` (anonymous / no resource); a `field` read of a key that
147
+ * is not present is **security-scope absence**, not an ordinary `undefined` (spec15pt2 §5-§7).
148
+ */
149
+ export interface AuthorizationPolicyScope {
150
+ principal: Record<string, unknown> | null | undefined;
151
+ resource: Record<string, unknown> | null | undefined;
152
+ operation: string;
153
+ }
154
+ /**
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:
158
+ *
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 }`.
167
+ *
168
+ * 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).
170
+ */
171
+ export declare function evaluateAuthorizationPolicyAllow(allow: unknown, scope: AuthorizationPolicyScope): AuthorizationCheckPart;
150
172
  /**
151
173
  * The distinct policy ids a graph node references for authorization — for dependency
152
174
  * analysis and `validateGraph` reference resolution. Total over malformed input.
@@ -26,6 +26,7 @@
26
26
  * closed with a structured diagnostic, never a native exception (spec15 §37).
27
27
  */
28
28
  import { OPERATION, PRINCIPAL, RESOURCE } from './authority.js';
29
+ import { EXPRESSION_KINDS } from './expressions.js';
29
30
  // --------------------------------------------------------------------- reserved scope ids
30
31
  /**
31
32
  * The canonical caller. The **same** reserved id `ActionDef.authorization` uses
@@ -136,6 +137,80 @@ function walkPolicyExpression(expression, refs, nondeterministic, seen = new Set
136
137
  * §37). Cross-node reference resolution (does an `authorizationPolicy` id point at a real
137
138
  * policy) is `validateGraph`'s job, not this function's.
138
139
  */
140
+ /**
141
+ * spec15pt2 §39-§41 (F2) — a **total** structural check on an `allow` expression tree.
142
+ * Every node must be a plain object whose `kind` is a real `EXPRESSION_KINDS` member and
143
+ * whose per-kind required children are present and themselves structurally valid. Returns a
144
+ * short reason on the first problem, `null` when the tree is well-formed. Never throws.
145
+ */
146
+ function malformedExpressionReason(expression, seen = new Set()) {
147
+ if (!isPlainObject(expression))
148
+ return 'a policy expression node is not an object';
149
+ if (seen.has(expression))
150
+ return 'a policy expression contains a cycle';
151
+ seen.add(expression);
152
+ const kind = expression.kind;
153
+ if (typeof kind !== 'string' || !EXPRESSION_KINDS.includes(kind)) {
154
+ return `unknown expression kind ${JSON.stringify(kind)}`;
155
+ }
156
+ const child = (name) => malformedExpressionReason(expression[name], seen);
157
+ const list = (name) => {
158
+ const arr = expression[name];
159
+ if (!Array.isArray(arr))
160
+ return `${kind}.${name} is not an array`;
161
+ for (const entry of arr) {
162
+ const r = malformedExpressionReason(entry, seen);
163
+ if (r)
164
+ return r;
165
+ }
166
+ return null;
167
+ };
168
+ switch (kind) {
169
+ case 'literal':
170
+ return 'value' in expression ? null : 'literal has no value';
171
+ case 'ref':
172
+ return typeof expression.targetId === 'string' ? null : 'ref has no targetId';
173
+ case 'field':
174
+ return (typeof expression.fieldId === 'string' ? null : 'field has no fieldId') ?? child('source');
175
+ case 'unary':
176
+ return (typeof expression.operator === 'string' ? null : 'unary has no operator') ?? child('operand');
177
+ case 'binary':
178
+ return (typeof expression.operator === 'string' ? null : 'binary has no operator') ?? child('left') ?? child('right');
179
+ case 'call':
180
+ return (typeof expression.function === 'string' ? null : 'call has no function') ?? list('arguments');
181
+ case 'conditional':
182
+ return child('condition') ?? child('whenTrue') ?? child('whenFalse');
183
+ case 'object': {
184
+ const entries = expression.entries;
185
+ if (!Array.isArray(entries))
186
+ return 'object has no entries array';
187
+ for (const e of entries) {
188
+ if (!isPlainObject(e) || typeof e.fieldId !== 'string')
189
+ return 'an object entry is malformed';
190
+ const r = malformedExpressionReason(e.value, seen);
191
+ if (r)
192
+ return r;
193
+ }
194
+ return null;
195
+ }
196
+ case 'filter':
197
+ case 'find':
198
+ case 'every':
199
+ case 'some':
200
+ return child('source') ?? child('predicate');
201
+ case 'map':
202
+ return child('source') ?? child('projection');
203
+ case 'sort':
204
+ case 'group':
205
+ return child('source') ?? child('by');
206
+ case 'flatten':
207
+ return child('source');
208
+ case 'expression-ref':
209
+ return typeof expression.targetId === 'string' ? null : 'expression-ref has no targetId';
210
+ default:
211
+ return `unsupported expression kind ${kind}`;
212
+ }
213
+ }
139
214
  export function authorizationPolicyProblems(policy) {
140
215
  const problems = [];
141
216
  const pid = isPlainObject(policy) ? String(policy.id ?? '<unknown>') : '<unknown>';
@@ -149,6 +224,12 @@ export function authorizationPolicyProblems(policy) {
149
224
  });
150
225
  return problems;
151
226
  }
227
+ // spec15pt2 F2 — a malformed `allow` tree is rejected structurally here, before compile,
228
+ // rather than surviving to a native exception at evaluation.
229
+ const malformed = malformedExpressionReason(policy.allow);
230
+ if (malformed) {
231
+ return [{ code: 'AUTHORIZATION_INVALID_POLICY', message: `Authorization policy ${pid} has a malformed 'allow' expression: ${malformed}` }];
232
+ }
152
233
  const refs = new Set();
153
234
  const nondeterministic = new Set();
154
235
  walkPolicyExpression(policy.allow, refs, nondeterministic);
@@ -253,6 +334,243 @@ export function decideAuthorization(input) {
253
334
  }
254
335
  return { decision: 'ALLOW', reason: 'allowed' };
255
336
  }
337
+ const U = { t: 'u' };
338
+ const E = { t: 'e' };
339
+ const C = (v) => ({ t: 'c', v });
340
+ function asBool(v) {
341
+ return v === true ? true : v === false ? false : Boolean(v);
342
+ }
343
+ function deepEq(a, b) {
344
+ if (a === b)
345
+ return true;
346
+ try {
347
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
348
+ }
349
+ catch {
350
+ return false;
351
+ }
352
+ }
353
+ /** Whether an expression tree reads only through `field` / `ref` rooted at PRINCIPAL / RESOURCE. */
354
+ function isSecurityRooted(expression) {
355
+ if (!isPlainObject(expression))
356
+ return false;
357
+ if (expression.kind === 'ref') {
358
+ const t = String(expression.targetId);
359
+ return t === AUTHZ_PRINCIPAL_SCOPE || t === AUTHZ_RESOURCE_SCOPE;
360
+ }
361
+ if (expression.kind === 'field')
362
+ return isSecurityRooted(expression.source);
363
+ return false;
364
+ }
365
+ function evalAuthz(expression, scope, seen) {
366
+ if (!isPlainObject(expression) || seen.has(expression))
367
+ return E;
368
+ seen.add(expression);
369
+ switch (expression.kind) {
370
+ case 'literal':
371
+ return C(expression.value);
372
+ case 'ref': {
373
+ const t = String(expression.targetId);
374
+ if (t === AUTHZ_PRINCIPAL_SCOPE)
375
+ return C(scope.principal ?? null);
376
+ if (t === AUTHZ_RESOURCE_SCOPE)
377
+ return C(scope.resource ?? null);
378
+ if (t === AUTHZ_OPERATION_SCOPE)
379
+ return C(scope.operation);
380
+ return E; // out of the closed scope — validation rejects it; be total
381
+ }
382
+ case 'field': {
383
+ const src = evalAuthz(expression.source, scope, seen);
384
+ if (src.t === 'e')
385
+ return E;
386
+ if (src.t === 'u')
387
+ return U;
388
+ const key = String(expression.fieldId);
389
+ const rooted = isSecurityRooted(expression.source);
390
+ const obj = src.v;
391
+ if (obj === null || obj === undefined || typeof obj !== 'object') {
392
+ return rooted ? U : C(null);
393
+ }
394
+ const present = Object.prototype.hasOwnProperty.call(obj, key) && obj[key] !== undefined;
395
+ if (!present)
396
+ return rooted ? U : C(null);
397
+ return C(obj[key]);
398
+ }
399
+ case 'unary': {
400
+ const operand = evalAuthz(expression.operand, scope, seen);
401
+ if (operand.t !== 'c')
402
+ return operand; // NOT unknown ⇒ unknown; NOT error ⇒ error (§9, §12)
403
+ return expression.operator === 'not' ? C(!asBool(operand.v)) : C(-Number(operand.v));
404
+ }
405
+ case 'binary': {
406
+ const op = String(expression.operator);
407
+ if (op === 'and') {
408
+ const l = evalAuthz(expression.left, scope, seen);
409
+ if (l.t === 'c' && !asBool(l.v))
410
+ return C(false); // FALSE AND _ ⇒ FALSE (short-circuit-independent)
411
+ const r = evalAuthz(expression.right, scope, seen);
412
+ if (l.t === 'e' || r.t === 'e')
413
+ return E;
414
+ if (r.t === 'c' && !asBool(r.v))
415
+ return C(false);
416
+ if (l.t === 'c' && r.t === 'c')
417
+ return C(true); // both concrete-true
418
+ return U;
419
+ }
420
+ if (op === 'or') {
421
+ const l = evalAuthz(expression.left, scope, seen);
422
+ if (l.t === 'c' && asBool(l.v))
423
+ return C(true); // TRUE OR _ ⇒ TRUE
424
+ const r = evalAuthz(expression.right, scope, seen);
425
+ if (l.t === 'e' || r.t === 'e')
426
+ return E;
427
+ if (r.t === 'c' && asBool(r.v))
428
+ return C(true);
429
+ if (l.t === 'c' && r.t === 'c')
430
+ return C(false); // both concrete-false
431
+ return U;
432
+ }
433
+ const l = evalAuthz(expression.left, scope, seen);
434
+ const r = evalAuthz(expression.right, scope, seen);
435
+ if (l.t === 'e' || r.t === 'e')
436
+ return E;
437
+ if (l.t === 'u' || r.t === 'u')
438
+ return U; // any comparison touching absence ⇒ non-satisfied (§8)
439
+ const a = l.v;
440
+ const b = r.v;
441
+ switch (op) {
442
+ case 'eq':
443
+ return C(deepEq(a, b));
444
+ case 'neq':
445
+ return C(!deepEq(a, b));
446
+ case 'lt':
447
+ return C(a < b);
448
+ case 'lte':
449
+ return C(a <= b);
450
+ case 'gt':
451
+ return C(a > b);
452
+ case 'gte':
453
+ return C(a >= b);
454
+ case 'add':
455
+ return C(a + b);
456
+ case 'subtract':
457
+ return C(a - b);
458
+ case 'multiply':
459
+ return C(a * b);
460
+ case 'divide':
461
+ return C(a / b);
462
+ default:
463
+ return E;
464
+ }
465
+ }
466
+ case 'conditional': {
467
+ const c = evalAuthz(expression.condition, scope, seen);
468
+ if (c.t !== 'c')
469
+ return c;
470
+ return asBool(c.v) ? evalAuthz(expression.whenTrue, scope, seen) : evalAuthz(expression.whenFalse, scope, seen);
471
+ }
472
+ case 'object': {
473
+ const out = {};
474
+ for (const entry of expression.entries ?? []) {
475
+ const v = evalAuthz(entry.value, scope, seen);
476
+ if (v.t === 'e')
477
+ return E;
478
+ if (v.t === 'u')
479
+ return U;
480
+ out[String(entry.fieldId)] = v.v;
481
+ }
482
+ return C(out);
483
+ }
484
+ case 'call': {
485
+ const args = [];
486
+ for (const argument of expression.arguments ?? []) {
487
+ const v = evalAuthz(argument, scope, seen);
488
+ if (v.t === 'e')
489
+ return E;
490
+ if (v.t === 'u')
491
+ return U; // conservative: any absent input ⇒ non-satisfied (spec15pt2 §57, §85)
492
+ args.push(v.v);
493
+ }
494
+ return applyPolicyBuiltin(String(expression.function), args);
495
+ }
496
+ default:
497
+ return E;
498
+ }
499
+ }
500
+ function applyPolicyBuiltin(fn, args) {
501
+ switch (fn) {
502
+ case 'required':
503
+ return C(args[0] !== null && args[0] !== undefined);
504
+ case 'is-empty':
505
+ return C(args[0] === null || args[0] === undefined || args[0] === '' || (Array.isArray(args[0]) && args[0].length === 0));
506
+ case 'non-empty':
507
+ return C(!(args[0] === null || args[0] === undefined || args[0] === '' || (Array.isArray(args[0]) && args[0].length === 0)));
508
+ case 'length':
509
+ return C(Array.isArray(args[0]) || typeof args[0] === 'string' ? args[0].length : 0);
510
+ case 'contains':
511
+ return C(typeof args[0] === 'string'
512
+ ? args[0].includes(String(args[1]))
513
+ : Array.isArray(args[0])
514
+ ? args[0].some((x) => deepEq(x, args[1]))
515
+ : false);
516
+ case 'one-of':
517
+ return C(args.slice(1).some((x) => deepEq(x, args[0])));
518
+ case 'concat':
519
+ return C(args.map((x) => (x === null || x === undefined ? '' : String(x))).join(''));
520
+ case 'to-string':
521
+ return C(args[0] === null || args[0] === undefined ? '' : String(args[0]));
522
+ case 'lowercase':
523
+ return C(String(args[0] ?? '').toLowerCase());
524
+ case 'trim':
525
+ return C(String(args[0] ?? '').trim());
526
+ case 'substring-before': {
527
+ const s = String(args[0] ?? '');
528
+ const i = s.indexOf(String(args[1] ?? ''));
529
+ return C(i < 0 ? '' : s.slice(0, i));
530
+ }
531
+ case 'substring-after': {
532
+ const s = String(args[0] ?? '');
533
+ const needle = String(args[1] ?? '');
534
+ const i = s.indexOf(needle);
535
+ return C(i < 0 ? '' : s.slice(i + needle.length));
536
+ }
537
+ case 'coalesce':
538
+ return C(args.find((x) => x !== null && x !== undefined) ?? null);
539
+ default:
540
+ return E; // an unknown / non-deterministic builtin fails closed
541
+ }
542
+ }
543
+ /**
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:
547
+ *
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 }`.
556
+ *
557
+ * 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).
559
+ */
560
+ export function evaluateAuthorizationPolicyAllow(allow, scope) {
561
+ let result;
562
+ try {
563
+ result = evalAuthz(allow, scope, new Set());
564
+ }
565
+ catch {
566
+ return { ok: false };
567
+ }
568
+ if (result.t === 'e')
569
+ return { ok: false };
570
+ if (result.t === 'u')
571
+ return { ok: true, value: false };
572
+ return { ok: true, value: result.v };
573
+ }
256
574
  /**
257
575
  * The distinct policy ids a graph node references for authorization — for dependency
258
576
  * analysis and `validateGraph` reference resolution. Total over malformed input.
@@ -100,6 +100,15 @@ export interface AuthorityCompatibilityKey {
100
100
  schemaFingerprint: string;
101
101
  serverContract: string;
102
102
  semanticFingerprint: string;
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).
110
+ */
111
+ authorizationRuntime?: string;
103
112
  }
104
113
  export declare function authorityCompatibilityKey(parts: AuthorityCompatibilityKey): AuthorityCompatibilityKey;
105
114
  /** A stable, comparable string form for storing on a durable work item (spec12 §43). */
@@ -138,6 +138,7 @@ export function authorityCompatibilityKey(parts) {
138
138
  schemaFingerprint: parts.schemaFingerprint,
139
139
  serverContract: parts.serverContract,
140
140
  semanticFingerprint: parts.semanticFingerprint,
141
+ ...(parts.authorizationRuntime !== undefined ? { authorizationRuntime: parts.authorizationRuntime } : {}),
141
142
  };
142
143
  }
143
144
  /** A stable, comparable string form for storing on a durable work item (spec12 §43). */
@@ -155,7 +156,10 @@ export function compareAuthorityCompatibility(a, b) {
155
156
  'schemaFingerprint',
156
157
  'serverContract',
157
158
  'semanticFingerprint',
159
+ 'authorizationRuntime',
158
160
  ];
159
- const mismatches = fields.filter((field) => a[field] !== b[field]);
161
+ // `authorizationRuntime` absent on both sides (a non-authorization graph) is a match;
162
+ // present-vs-absent (alpha.1 stored key vs alpha.2) is a mismatch (spec15pt2 §35, §76).
163
+ const mismatches = fields.filter((field) => (a[field] ?? null) !== (b[field] ?? null));
160
164
  return { compatible: mismatches.length === 0, mismatches };
161
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.15.0-alpha.1",
3
+ "version": "0.15.0-alpha.2",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",