@atlasauth/pca-analyzer 0.0.0-stage → 0.1.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/README.md +89 -2
- package/dist/caveats.d.ts +22 -0
- package/dist/caveats.js +88 -0
- package/dist/caveats.js.map +1 -0
- package/dist/enumerate.d.ts +68 -0
- package/dist/enumerate.js +375 -0
- package/dist/enumerate.js.map +1 -0
- package/dist/field.d.ts +42 -0
- package/dist/field.js +232 -0
- package/dist/field.js.map +1 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +374 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +30 -3
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @atlasauth/pca-analyzer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Static analyzability of authority.** Proof-Carrying Authority can prove a quantitative trust-budget
|
|
4
|
+
bound and verify one action, but on its own it cannot statically answer *"what can this whole delegated
|
|
5
|
+
authority ever do?"* This package decides that over PCA's predicate/caveat policy — the conceptual gap
|
|
6
|
+
the roadmap flagged — without a heavyweight SMT dependency.
|
|
7
|
+
|
|
8
|
+
It is a sound, **bounded** decision procedure in the spirit of AWS Cedar's symbolic compiler (SymCC,
|
|
9
|
+
arXiv:2403.04651), which decides policy equivalence / subsumption / always-allow-deny with
|
|
10
|
+
counterexamples. Instead of discharging to Z3, this analyzer exploits a *finite-distinguishing-value*
|
|
11
|
+
property of PCA's predicate theory and decides by enumeration against the **real** `evaluatePredicates`
|
|
12
|
+
from `@atlasauth/pca`.
|
|
13
|
+
|
|
14
|
+
## Queries
|
|
15
|
+
|
|
16
|
+
All take a `CompiledPolicy` (from `compilePolicy` / `agent().policy`) or a bare
|
|
17
|
+
`{ predicates, caveats? }`. Every negative/relational verdict carries a concrete counterexample or
|
|
18
|
+
witness.
|
|
19
|
+
|
|
20
|
+
| Function | Decides |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `reachable(policy, action)` | Can any input complete `(verb, resource, params)` into an ALLOW? Returns a verified `witness`. |
|
|
23
|
+
| `alwaysDenies(policy, verb?)` | Is the policy vacuous (denies everything / everything for `verb`)? |
|
|
24
|
+
| `alwaysAllows(policy, verb)` | Is the policy total for `verb` (admits every action of it)? |
|
|
25
|
+
| `subsumes(a, b)` | Does `a` permit everything `b` does (`admitted(b) ⊆ admitted(a)`)? The delegation-safety check: a child must be subsumed by its parent. |
|
|
26
|
+
| `disjoint(a, b)` | Do the two admit no common action? |
|
|
27
|
+
| `equivalent(a, b)` | Do the two admit exactly the same actions? |
|
|
28
|
+
| `intentConformance(policy, intent)` | Does the policy admit **only** actions inside a declared intent envelope (verbs + resource scopes + amount ceiling)? |
|
|
29
|
+
|
|
30
|
+
`intentConformance` is the safety check distinct from the runtime trust-budget. It catches the
|
|
31
|
+
*"a $14k renewal under a $10k cap on an approved vendor still violates intent"* class: an action that
|
|
32
|
+
passes the policy's predicates and stays under budget, yet falls outside what the principal intended,
|
|
33
|
+
because the policy's own ceiling is higher than (or absent relative to) the intent cap.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { subsumes, intentConformance } from '@atlasauth/pca-analyzer';
|
|
37
|
+
|
|
38
|
+
// Delegation safety: a child capability must not widen its parent.
|
|
39
|
+
const ok = subsumes(parentPolicy, childPolicy).subsumes; // false => counterexample attached
|
|
40
|
+
|
|
41
|
+
// Intent conformance: does the authority stay inside what was meant?
|
|
42
|
+
const r = intentConformance(policy, { verbs: ['stripe.refund'], maxAmount: 10000 });
|
|
43
|
+
// r.conforms === false, r.violations[].kinds includes 'over-amount' for the $14k action
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## How it decides (the engine)
|
|
47
|
+
|
|
48
|
+
PCA's `where` conditions are a conjunction of threshold tests (`lt/lte/gt/gte/eq` on numbers),
|
|
49
|
+
membership tests (`eq/ne/in/nin` over finite values) and string `prefix` tests; predicates match on a
|
|
50
|
+
verb allowlist and a resource matcher; the envelope caveats are append-only and conjunctive. The
|
|
51
|
+
admitted region of a policy is therefore a **union of cells** of the arrangement defined by the literals
|
|
52
|
+
that appear in it. The analyzer builds one representative value per cell — every literal and numeric
|
|
53
|
+
threshold, the midpoints between consecutive thresholds, boundary points just outside the range, prefix
|
|
54
|
+
probes, a *fresh* verb / resource / value matching none of the literals, and an *absent* probe for each
|
|
55
|
+
field — then evaluates both policies at every representative with the real evaluator. Because that set
|
|
56
|
+
is distinguishing, agreeing on all representatives implies agreeing on all actions, so the relational
|
|
57
|
+
queries are decided exactly, and any mismatch is a real counterexample. Caveat satisfiability is decided
|
|
58
|
+
separately (`analyzeCaveats`): the context (time window, blast radius, depth, rate) is taken as
|
|
59
|
+
favourably chosen, and the one action-tied caveat, `reversibility_max`, becomes a per-action cap.
|
|
60
|
+
|
|
61
|
+
## Decided exactly vs. sound-over-approximated
|
|
62
|
+
|
|
63
|
+
**Decided exactly** (the fragment):
|
|
64
|
+
|
|
65
|
+
- verb allowlists (`string`, `string[]`, `'*'`);
|
|
66
|
+
- resource matchers that are exact, `'*'`, or a trailing-`*` **prefix**;
|
|
67
|
+
- numeric `where` over `lt/lte/gt/gte/eq` (the interval engine; merges bounds, detects empty
|
|
68
|
+
conjunctions such as `amount >= 100 AND amount <= 10`);
|
|
69
|
+
- finite-domain `eq/ne/in/nin` and string `prefix` (the set engine — the fresh/absent probes make
|
|
70
|
+
unbounded string domains decidable for these operators);
|
|
71
|
+
- `exists` (via the absent probe);
|
|
72
|
+
- the conjunctive envelope caveats: `expires`, `not_before` (empty-window detection), `rate`,
|
|
73
|
+
`max_blast_radius`, `reversibility_max`, `delegation_depth`, `budget_alloc`.
|
|
74
|
+
|
|
75
|
+
**Sound but over-approximated** — the result is reported with `approximate: true` and **fails safe**
|
|
76
|
+
(never claims *subsumed / always-deny / disjoint / equivalent / conforms* without proof; falls back to
|
|
77
|
+
*not-subsumed / reachable / not-disjoint / not-equivalent / non-conforming*):
|
|
78
|
+
|
|
79
|
+
- **`re:` regex resource matchers** — a regex relation (containment/overlap) is not decided; the
|
|
80
|
+
resource dimension cannot be soundly enumerated. (A regex is still evaluated *exactly* against a fully
|
|
81
|
+
concrete resource in `reachable`, where there is nothing to enumerate.)
|
|
82
|
+
- **cross-field `ref` conditions** — a comparison whose operand is another field (`a != env.b`); the two
|
|
83
|
+
fields co-vary and are not resolved statically.
|
|
84
|
+
- **whole-`action.params` conditions** (a condition on the container rather than a `.subfield`).
|
|
85
|
+
- **grid overflow** — a representative grid larger than `maxCombinations` (default 200,000); no
|
|
86
|
+
enumeration is performed.
|
|
87
|
+
|
|
88
|
+
The soundness contract: a proven verdict (`approximate` falsy) is correct for the fragment above; an
|
|
89
|
+
`approximate` verdict is never a false "safe". An external Z3 / SMT-LIB backend for the regex and
|
|
90
|
+
cross-field fragments is a possible future stretch; it is **not** required for the guarantees here.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Caveat } from '@atlasauth/pca';
|
|
2
|
+
/**
|
|
3
|
+
* Static satisfiability of a PCA envelope caveat set (the leaf's cumulative, append-only, conjunctive
|
|
4
|
+
* caveats). This answers "is there ANY runtime context under which these caveats can all hold?" — the
|
|
5
|
+
* global gate on whether the authority can ever admit anything.
|
|
6
|
+
*
|
|
7
|
+
* The caveats constrain the runtime CaveatContext (now, blast radius, delegation depth, rate window,
|
|
8
|
+
* reversibility class), not the action's predicate fields. The analyzer treats the context as chosen
|
|
9
|
+
* favourably (the most permissive the authority could ever encounter) — the right stance for "what can
|
|
10
|
+
* this authority EVER do". The one caveat tied to the action itself is `reversibility_max`, whose cap
|
|
11
|
+
* is returned so the per-action check can enforce `class(action) <= cap`.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors `envelopeCaveatEvaluator` in @atlasauth/pca (unknown/ malformed => fail closed).
|
|
14
|
+
*/
|
|
15
|
+
export interface CaveatAnalysis {
|
|
16
|
+
/** Some context satisfies every caveat. */
|
|
17
|
+
sat: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
/** Strictest `reversibility_max` cap as an index into REVERSIBILITY_ORDER, if any caveat sets one. */
|
|
20
|
+
revCap?: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function analyzeCaveats(caveats: readonly Caveat[] | undefined): CaveatAnalysis;
|
package/dist/caveats.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeCaveats = analyzeCaveats;
|
|
4
|
+
const types_1 = require("./types");
|
|
5
|
+
function finite(v) {
|
|
6
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
7
|
+
}
|
|
8
|
+
function analyzeCaveats(caveats) {
|
|
9
|
+
if (!Array.isArray(caveats))
|
|
10
|
+
return { sat: false, reason: 'caveats are not an array' };
|
|
11
|
+
let notBeforeMax = -Infinity; // the action can only happen at now >= this
|
|
12
|
+
let expiresMin = Infinity; // ...and now < this
|
|
13
|
+
let revCap;
|
|
14
|
+
for (const cv of caveats) {
|
|
15
|
+
if (cv === null || typeof cv !== 'object' || typeof cv.type !== 'string') {
|
|
16
|
+
return { sat: false, reason: 'malformed caveat (not an object with a string type)' };
|
|
17
|
+
}
|
|
18
|
+
switch (cv.type) {
|
|
19
|
+
case 'expires': {
|
|
20
|
+
const at = finite(cv.at);
|
|
21
|
+
if (at === undefined)
|
|
22
|
+
return { sat: false, reason: 'expires.at is not a finite number' };
|
|
23
|
+
expiresMin = Math.min(expiresMin, at);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
case 'not_before': {
|
|
27
|
+
const at = finite(cv.at);
|
|
28
|
+
if (at === undefined)
|
|
29
|
+
return { sat: false, reason: 'not_before.at is not a finite number' };
|
|
30
|
+
notBeforeMax = Math.max(notBeforeMax, at);
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
case 'rate': {
|
|
34
|
+
const max = finite(cv.max);
|
|
35
|
+
const per = finite(cv.per_secs);
|
|
36
|
+
if (max === undefined || per === undefined || per <= 0) {
|
|
37
|
+
return { sat: false, reason: 'rate caveat requires finite max and per_secs > 0' };
|
|
38
|
+
}
|
|
39
|
+
// With zero prior actions the count is 0, so the gate `0 < max` needs max >= 1.
|
|
40
|
+
if (max < 1)
|
|
41
|
+
return { sat: false, reason: `rate.max ${max} < 1 forbids every action` };
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case 'max_blast_radius': {
|
|
45
|
+
const max = finite(cv.max);
|
|
46
|
+
if (max === undefined)
|
|
47
|
+
return { sat: false, reason: 'max_blast_radius.max is not a finite number' };
|
|
48
|
+
// The least blast radius is 0, so this is satisfiable iff max >= 0.
|
|
49
|
+
if (max < 0)
|
|
50
|
+
return { sat: false, reason: 'max_blast_radius.max < 0 forbids every action' };
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case 'reversibility_max': {
|
|
54
|
+
const idx = types_1.REVERSIBILITY_ORDER.indexOf(String(cv.class));
|
|
55
|
+
if (idx < 0)
|
|
56
|
+
return { sat: false, reason: `reversibility_max.class '${String(cv.class)}' is unknown` };
|
|
57
|
+
revCap = revCap === undefined ? idx : Math.min(revCap, idx);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case 'delegation_depth': {
|
|
61
|
+
const max = finite(cv.max);
|
|
62
|
+
if (max === undefined)
|
|
63
|
+
return { sat: false, reason: 'delegation_depth.max is not a finite number' };
|
|
64
|
+
// The least depth is 0, so this is satisfiable iff max >= 0.
|
|
65
|
+
if (max < 0)
|
|
66
|
+
return { sat: false, reason: 'delegation_depth.max < 0 forbids every action' };
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'budget_alloc': {
|
|
70
|
+
const lim = finite(cv.limit);
|
|
71
|
+
if (lim === undefined || lim < 0) {
|
|
72
|
+
return { sat: false, reason: 'budget_alloc.limit must be a finite number >= 0' };
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
default:
|
|
77
|
+
return { sat: false, reason: `unknown caveat type '${cv.type}' (fail closed)` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (notBeforeMax !== -Infinity && expiresMin !== Infinity && notBeforeMax >= expiresMin) {
|
|
81
|
+
return {
|
|
82
|
+
sat: false,
|
|
83
|
+
reason: `not_before (${notBeforeMax}) >= expires (${expiresMin}): the validity window is empty`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { sat: true, revCap };
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=caveats.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"caveats.js","sourceRoot":"","sources":["../src/caveats.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Caveat, Condition, Predicate } from '@atlasauth/pca';
|
|
2
|
+
import type { AnalyzerAction, PolicyInput } from './types';
|
|
3
|
+
type Cell = unknown;
|
|
4
|
+
/** Default cap on the representative grid size (product of all dimensions). */
|
|
5
|
+
export declare const DEFAULT_MAX_COMBINATIONS = 200000;
|
|
6
|
+
export interface PolicyModel {
|
|
7
|
+
predicates: Predicate[];
|
|
8
|
+
caveats: Caveat[];
|
|
9
|
+
caveatSat: boolean;
|
|
10
|
+
caveatReason?: string;
|
|
11
|
+
/** Strictest `reversibility_max` cap (index into REVERSIBILITY_ORDER), if any. */
|
|
12
|
+
revCap?: number;
|
|
13
|
+
/** Non-empty when the policy uses features the analyzer cannot decide exactly. */
|
|
14
|
+
undecidable: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare function normalizePolicy(p: PolicyInput): {
|
|
17
|
+
predicates: Predicate[];
|
|
18
|
+
caveats: Caveat[];
|
|
19
|
+
};
|
|
20
|
+
export declare function toModel(p: PolicyInput): PolicyModel;
|
|
21
|
+
export interface Probes {
|
|
22
|
+
verbs: string[];
|
|
23
|
+
resources: string[];
|
|
24
|
+
/** `undefined` means "no reversibility dimension" (no policy sets a `reversibility_max`). */
|
|
25
|
+
revClasses: (string | undefined)[];
|
|
26
|
+
fields: {
|
|
27
|
+
path: string;
|
|
28
|
+
values: Cell[];
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
export interface ProbeInputs {
|
|
32
|
+
predicateSets: Predicate[][];
|
|
33
|
+
extraVerbs?: string[];
|
|
34
|
+
extraResources?: string[];
|
|
35
|
+
extraFieldConditions?: {
|
|
36
|
+
path: string;
|
|
37
|
+
cond: Condition;
|
|
38
|
+
}[];
|
|
39
|
+
/** Vary the reversibility class over all three classes (needed when any policy caps reversibility). */
|
|
40
|
+
varyReversibility: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function collectProbes(inputs: ProbeInputs): Probes;
|
|
43
|
+
export declare function productSize(pr: Probes): number;
|
|
44
|
+
export interface Rep {
|
|
45
|
+
verb: string;
|
|
46
|
+
resource: string;
|
|
47
|
+
revClass: string | undefined;
|
|
48
|
+
/** Field path -> value, excluding absent fields. */
|
|
49
|
+
assignments: ReadonlyMap<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
export declare function representatives(pr: Probes): Generator<Rep>;
|
|
52
|
+
/** Does the policy admit this representative action? (predicate match AND static caveat gate.) */
|
|
53
|
+
export declare function admits(model: PolicyModel, rep: Rep): boolean;
|
|
54
|
+
/** Concrete admission of a fully-specified action (used by reachable's fast path; exact). */
|
|
55
|
+
export declare function admitsAction(model: PolicyModel, action: AnalyzerAction): boolean;
|
|
56
|
+
export declare function repToAction(rep: Rep): AnalyzerAction;
|
|
57
|
+
export interface SearchOutcome {
|
|
58
|
+
/** A representative for which the predicate returned true, if the search stopped on one. */
|
|
59
|
+
hit?: Rep;
|
|
60
|
+
/** The grid exceeded the configured bound; no enumeration was performed. */
|
|
61
|
+
overflow: boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Walk the representative grid, stopping at the first rep for which `predicate` is true. Returns
|
|
65
|
+
* `overflow: true` (without walking) when the grid is larger than `max`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function search(pr: Probes, predicate: (rep: Rep) => boolean, max: number): SearchOutcome;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_MAX_COMBINATIONS = void 0;
|
|
4
|
+
exports.normalizePolicy = normalizePolicy;
|
|
5
|
+
exports.toModel = toModel;
|
|
6
|
+
exports.collectProbes = collectProbes;
|
|
7
|
+
exports.productSize = productSize;
|
|
8
|
+
exports.representatives = representatives;
|
|
9
|
+
exports.admits = admits;
|
|
10
|
+
exports.admitsAction = admitsAction;
|
|
11
|
+
exports.repToAction = repToAction;
|
|
12
|
+
exports.search = search;
|
|
13
|
+
const pca_1 = require("@atlasauth/pca");
|
|
14
|
+
const caveats_1 = require("./caveats");
|
|
15
|
+
const field_1 = require("./field");
|
|
16
|
+
const types_1 = require("./types");
|
|
17
|
+
/**
|
|
18
|
+
* The bounded decision engine. It builds a finite set of representative actions ("the grid") from the
|
|
19
|
+
* literals, thresholds, prefixes, verbs and resource matchers that appear in the policies (and, for
|
|
20
|
+
* intent conformance, the intent). Because PCA's `where` theory is a boolean combination of
|
|
21
|
+
* threshold/membership/prefix tests, each policy's admitted region is a union of grid cells, so one
|
|
22
|
+
* representative per cell is DISTINGUISHING: any two policies that differ on some action also differ on
|
|
23
|
+
* some representative. Evaluating the REAL `evaluatePredicates` at each representative (plus the static
|
|
24
|
+
* caveat gate) therefore decides reachability, subsumption, disjointness and equivalence EXACTLY for
|
|
25
|
+
* the decided fragment, and yields a concrete counterexample whenever a relation fails.
|
|
26
|
+
*
|
|
27
|
+
* Out-of-fragment features (`re:` regex resources, cross-field `ref` conditions) and grids larger than
|
|
28
|
+
* the bound are detected and reported as `approximate`, and every verdict then fails safe.
|
|
29
|
+
*/
|
|
30
|
+
const ABSENT = Symbol('analyzer-absent-field');
|
|
31
|
+
const FRESH_VERB = '\u0000atlas-analyzer::fresh-verb\u0000';
|
|
32
|
+
const FRESH_RESOURCE = '\u0000atlas-analyzer::fresh-resource\u0000';
|
|
33
|
+
/** Default cap on the representative grid size (product of all dimensions). */
|
|
34
|
+
exports.DEFAULT_MAX_COMBINATIONS = 200_000;
|
|
35
|
+
function normalizePolicy(p) {
|
|
36
|
+
const predicates = Array.isArray(p.predicates) ? p.predicates : [];
|
|
37
|
+
const caveats = 'caveats' in p && Array.isArray(p.caveats) ? p.caveats : [];
|
|
38
|
+
return { predicates, caveats };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Collect over-approximation reasons from one condition tree. Boolean groupings (all_of/any_of/not)
|
|
42
|
+
* are DECIDED EXACTLY as long as every leaf uses a decidable op — the admitted region stays a boolean
|
|
43
|
+
* combination of the same per-field cells, so the representative grid remains distinguishing — so the
|
|
44
|
+
* grouping itself is not undecidable; only its hard leaves are. The genuinely-hard leaf ops
|
|
45
|
+
* (`like`/`matches`/`is_a`/`member_of`), a cross-field `ref`, and a whole-`action.params` condition
|
|
46
|
+
* force a conservative over-approximation (verdict fails safe).
|
|
47
|
+
*/
|
|
48
|
+
function conditionUndecidable(c, reasons) {
|
|
49
|
+
if (c === null || typeof c !== 'object')
|
|
50
|
+
return;
|
|
51
|
+
if ((0, pca_1.isGroupCondition)(c)) {
|
|
52
|
+
if ('all_of' in c && Array.isArray(c.all_of))
|
|
53
|
+
for (const x of c.all_of)
|
|
54
|
+
conditionUndecidable(x, reasons);
|
|
55
|
+
else if ('any_of' in c && Array.isArray(c.any_of))
|
|
56
|
+
for (const x of c.any_of)
|
|
57
|
+
conditionUndecidable(x, reasons);
|
|
58
|
+
else if ('not' in c)
|
|
59
|
+
conditionUndecidable(c.not, reasons);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!(0, pca_1.isLeafCondition)(c))
|
|
63
|
+
return;
|
|
64
|
+
if (c.ref !== undefined) {
|
|
65
|
+
reasons.add('cross-field `ref` condition — the relation between two fields is not resolved');
|
|
66
|
+
}
|
|
67
|
+
if (c.field === 'action.params') {
|
|
68
|
+
reasons.add('condition on the whole `action.params` object is not enumerated precisely');
|
|
69
|
+
}
|
|
70
|
+
if (c.op === 'like' || c.op === 'matches' || c.op === 'is_a' || c.op === 'member_of') {
|
|
71
|
+
reasons.add(`\`${c.op}\` condition — matched by over-approximation, not enumerated exactly`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function detectUndecidable(predicates) {
|
|
75
|
+
const reasons = new Set();
|
|
76
|
+
for (const p of predicates) {
|
|
77
|
+
if (p === null || typeof p !== 'object')
|
|
78
|
+
continue;
|
|
79
|
+
const r = p.resource;
|
|
80
|
+
if (typeof r === 'string' && r.startsWith('re:')) {
|
|
81
|
+
reasons.add('re: regex resource matcher — resource dimension cannot be soundly enumerated');
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(p.where))
|
|
84
|
+
for (const c of p.where)
|
|
85
|
+
conditionUndecidable(c, reasons);
|
|
86
|
+
}
|
|
87
|
+
return [...reasons];
|
|
88
|
+
}
|
|
89
|
+
function toModel(p) {
|
|
90
|
+
const { predicates, caveats } = normalizePolicy(p);
|
|
91
|
+
const ca = (0, caveats_1.analyzeCaveats)(caveats);
|
|
92
|
+
return {
|
|
93
|
+
predicates,
|
|
94
|
+
caveats,
|
|
95
|
+
caveatSat: ca.sat,
|
|
96
|
+
caveatReason: ca.reason,
|
|
97
|
+
revCap: ca.revCap,
|
|
98
|
+
undecidable: detectUndecidable(predicates),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function addVerbLiterals(c, into) {
|
|
102
|
+
const v = c.value;
|
|
103
|
+
if (c.op === 'eq' || c.op === 'ne' || c.op === 'prefix' || c.op === 'like' || c.op === 'is_a') {
|
|
104
|
+
if (typeof v === 'string')
|
|
105
|
+
into.add(v);
|
|
106
|
+
}
|
|
107
|
+
else if ((c.op === 'in' || c.op === 'nin') && Array.isArray(v)) {
|
|
108
|
+
for (const x of v)
|
|
109
|
+
if (typeof x === 'string')
|
|
110
|
+
into.add(x);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function collectProbes(inputs) {
|
|
114
|
+
const verbLiterals = new Set(inputs.extraVerbs ?? []);
|
|
115
|
+
const resourceLiterals = new Set();
|
|
116
|
+
const fieldConds = new Map();
|
|
117
|
+
const addFieldCond = (path, c) => {
|
|
118
|
+
const arr = fieldConds.get(path) ?? [];
|
|
119
|
+
arr.push(c);
|
|
120
|
+
fieldConds.set(path, arr);
|
|
121
|
+
};
|
|
122
|
+
const classifyResource = (r) => {
|
|
123
|
+
if (r === undefined || r === '*')
|
|
124
|
+
return; // universal: FRESH_RESOURCE represents it
|
|
125
|
+
if (r.startsWith('re:'))
|
|
126
|
+
return; // undecidable: FRESH_RESOURCE stands in (flagged on the model)
|
|
127
|
+
if (r.endsWith('*')) {
|
|
128
|
+
const body = r.slice(0, -1);
|
|
129
|
+
resourceLiterals.add(body);
|
|
130
|
+
resourceLiterals.add(body + 'x');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
resourceLiterals.add(r);
|
|
134
|
+
};
|
|
135
|
+
const handleCond = (c) => {
|
|
136
|
+
if (c === null || typeof c !== 'object')
|
|
137
|
+
return;
|
|
138
|
+
// Recurse through boolean groupings and bucket their leaves (keeps the grid distinguishing for
|
|
139
|
+
// nested boolean logic over decidable ops).
|
|
140
|
+
if ((0, pca_1.isGroupCondition)(c)) {
|
|
141
|
+
if ('all_of' in c && Array.isArray(c.all_of))
|
|
142
|
+
for (const x of c.all_of)
|
|
143
|
+
handleCond(x);
|
|
144
|
+
else if ('any_of' in c && Array.isArray(c.any_of))
|
|
145
|
+
for (const x of c.any_of)
|
|
146
|
+
handleCond(x);
|
|
147
|
+
else if ('not' in c)
|
|
148
|
+
handleCond(c.not);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!(0, pca_1.isLeafCondition)(c))
|
|
152
|
+
return;
|
|
153
|
+
if (c.field === 'action.verb') {
|
|
154
|
+
addVerbLiterals(c, verbLiterals);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (c.field === 'action.resource') {
|
|
158
|
+
if (c.op === 'prefix' && typeof c.value === 'string')
|
|
159
|
+
classifyResource(c.value + '*');
|
|
160
|
+
else if (typeof c.value === 'string')
|
|
161
|
+
classifyResource(c.value);
|
|
162
|
+
else if (Array.isArray(c.value))
|
|
163
|
+
for (const x of c.value)
|
|
164
|
+
if (typeof x === 'string')
|
|
165
|
+
classifyResource(x);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
addFieldCond(c.field, c);
|
|
169
|
+
};
|
|
170
|
+
for (const list of inputs.predicateSets) {
|
|
171
|
+
for (const p of list) {
|
|
172
|
+
if (p === null || typeof p !== 'object')
|
|
173
|
+
continue;
|
|
174
|
+
if (typeof p.verb === 'string') {
|
|
175
|
+
if (p.verb !== '*')
|
|
176
|
+
verbLiterals.add(p.verb);
|
|
177
|
+
}
|
|
178
|
+
else if (Array.isArray(p.verb)) {
|
|
179
|
+
for (const v of p.verb)
|
|
180
|
+
if (typeof v === 'string' && v !== '*')
|
|
181
|
+
verbLiterals.add(v);
|
|
182
|
+
}
|
|
183
|
+
classifyResource(p.resource);
|
|
184
|
+
if (Array.isArray(p.where))
|
|
185
|
+
for (const c of p.where)
|
|
186
|
+
handleCond(c);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const r of inputs.extraResources ?? [])
|
|
190
|
+
classifyResource(r);
|
|
191
|
+
for (const ec of inputs.extraFieldConditions ?? [])
|
|
192
|
+
handleCond({ ...ec.cond, field: ec.path });
|
|
193
|
+
const fields = [...fieldConds.entries()].map(([path, conds]) => ({
|
|
194
|
+
path,
|
|
195
|
+
values: [...(0, field_1.fieldProbes)(conds), ABSENT],
|
|
196
|
+
}));
|
|
197
|
+
return {
|
|
198
|
+
verbs: [...verbLiterals, FRESH_VERB],
|
|
199
|
+
resources: [...resourceLiterals, FRESH_RESOURCE],
|
|
200
|
+
revClasses: inputs.varyReversibility ? [...types_1.REVERSIBILITY_ORDER] : [undefined],
|
|
201
|
+
fields,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function productSize(pr) {
|
|
205
|
+
let n = pr.verbs.length * pr.resources.length * pr.revClasses.length;
|
|
206
|
+
for (const f of pr.fields)
|
|
207
|
+
n *= f.values.length;
|
|
208
|
+
return n;
|
|
209
|
+
}
|
|
210
|
+
function* enumerateFields(fields, i, acc) {
|
|
211
|
+
if (i >= fields.length) {
|
|
212
|
+
yield new Map(acc);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const f = fields[i];
|
|
216
|
+
if (f === undefined) {
|
|
217
|
+
yield new Map(acc);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
for (const v of f.values) {
|
|
221
|
+
if (v === ABSENT) {
|
|
222
|
+
acc.delete(f.path);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
acc.set(f.path, v);
|
|
226
|
+
}
|
|
227
|
+
yield* enumerateFields(fields, i + 1, acc);
|
|
228
|
+
}
|
|
229
|
+
acc.delete(f.path);
|
|
230
|
+
}
|
|
231
|
+
function* representatives(pr) {
|
|
232
|
+
for (const verb of pr.verbs) {
|
|
233
|
+
for (const resource of pr.resources) {
|
|
234
|
+
for (const revClass of pr.revClasses) {
|
|
235
|
+
for (const assignments of enumerateFields(pr.fields, 0, new Map())) {
|
|
236
|
+
yield { verb, resource, revClass, assignments };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function branch() {
|
|
243
|
+
return { leaf: false, children: new Map() };
|
|
244
|
+
}
|
|
245
|
+
function insert(root, segs, value) {
|
|
246
|
+
let node = root;
|
|
247
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
248
|
+
const key = segs[i];
|
|
249
|
+
if (key === undefined || node.leaf)
|
|
250
|
+
return;
|
|
251
|
+
const next = node.children.get(key);
|
|
252
|
+
if (next === undefined || next.leaf) {
|
|
253
|
+
const created = branch();
|
|
254
|
+
node.children.set(key, created);
|
|
255
|
+
node = created;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
node = next;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const last = segs[segs.length - 1];
|
|
262
|
+
if (last !== undefined && !node.leaf)
|
|
263
|
+
node.children.set(last, { leaf: true, value });
|
|
264
|
+
}
|
|
265
|
+
function materialize(node) {
|
|
266
|
+
const out = {};
|
|
267
|
+
if (node.leaf)
|
|
268
|
+
return out;
|
|
269
|
+
for (const [key, child] of node.children) {
|
|
270
|
+
out[key] = child.leaf ? child.value : materialize(child);
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
function buildRoots(rep) {
|
|
275
|
+
const roots = new Map([
|
|
276
|
+
['action', branch()],
|
|
277
|
+
['subject', branch()],
|
|
278
|
+
['env', branch()],
|
|
279
|
+
]);
|
|
280
|
+
for (const [path, value] of rep.assignments) {
|
|
281
|
+
const segs = path.split('.');
|
|
282
|
+
const first = segs[0];
|
|
283
|
+
if (first === undefined || segs.length < 2)
|
|
284
|
+
continue;
|
|
285
|
+
const root = roots.get(first);
|
|
286
|
+
if (root === undefined)
|
|
287
|
+
continue; // only action / subject / env roots are addressable
|
|
288
|
+
insert(root, segs.slice(1), value);
|
|
289
|
+
}
|
|
290
|
+
const actionTree = roots.get('action');
|
|
291
|
+
const subjectTree = roots.get('subject');
|
|
292
|
+
const envTree = roots.get('env');
|
|
293
|
+
const action = { verb: rep.verb, resource: rep.resource, params: {} };
|
|
294
|
+
if (actionTree !== undefined && !actionTree.leaf) {
|
|
295
|
+
for (const [key, child] of actionTree.children) {
|
|
296
|
+
if (key === 'verb' || key === 'resource')
|
|
297
|
+
continue; // fixed from the rep
|
|
298
|
+
if (key === 'params') {
|
|
299
|
+
// `action.params.*` branches materialize to a typed record; a whole-`params` leaf is flagged
|
|
300
|
+
// undecidable upstream, so it is safe to ignore here (params stays an empty, present object).
|
|
301
|
+
if (!child.leaf)
|
|
302
|
+
action.params = materialize(child);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
action[key] = child.leaf ? child.value : materialize(child);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (rep.revClass !== undefined)
|
|
310
|
+
action.reversibility_class = rep.revClass;
|
|
311
|
+
const subject = subjectTree === undefined || subjectTree.leaf ? {} : materialize(subjectTree);
|
|
312
|
+
const env = envTree === undefined || envTree.leaf ? {} : materialize(envTree);
|
|
313
|
+
return { action, subject, env };
|
|
314
|
+
}
|
|
315
|
+
function contextFromRoots(roots) {
|
|
316
|
+
return { action: roots.action, subject: roots.subject, env: roots.env };
|
|
317
|
+
}
|
|
318
|
+
function reversibilityOk(model, revClass) {
|
|
319
|
+
if (model.revCap === undefined)
|
|
320
|
+
return true;
|
|
321
|
+
if (revClass === undefined)
|
|
322
|
+
return false; // a reversibility_max caveat denies an action with no class
|
|
323
|
+
const idx = types_1.REVERSIBILITY_ORDER.indexOf(revClass);
|
|
324
|
+
return idx >= 0 && idx <= model.revCap;
|
|
325
|
+
}
|
|
326
|
+
/** Does the policy admit this representative action? (predicate match AND static caveat gate.) */
|
|
327
|
+
function admits(model, rep) {
|
|
328
|
+
if (!model.caveatSat)
|
|
329
|
+
return false;
|
|
330
|
+
if (!reversibilityOk(model, rep.revClass))
|
|
331
|
+
return false;
|
|
332
|
+
const ctx = contextFromRoots(buildRoots(rep));
|
|
333
|
+
return (0, pca_1.evaluatePredicates)(model.predicates, ctx).allowed;
|
|
334
|
+
}
|
|
335
|
+
/** Concrete admission of a fully-specified action (used by reachable's fast path; exact). */
|
|
336
|
+
function admitsAction(model, action) {
|
|
337
|
+
if (!model.caveatSat)
|
|
338
|
+
return false;
|
|
339
|
+
if (!reversibilityOk(model, action.reversibilityClass))
|
|
340
|
+
return false;
|
|
341
|
+
const act = { verb: action.verb, resource: action.resource, params: { ...(action.params ?? {}) } };
|
|
342
|
+
if (action.reversibilityClass !== undefined)
|
|
343
|
+
act.reversibility_class = action.reversibilityClass;
|
|
344
|
+
const ctx = {
|
|
345
|
+
action: act,
|
|
346
|
+
subject: { ...(action.subject ?? {}) },
|
|
347
|
+
env: { ...(action.env ?? {}) },
|
|
348
|
+
};
|
|
349
|
+
return (0, pca_1.evaluatePredicates)(model.predicates, ctx).allowed;
|
|
350
|
+
}
|
|
351
|
+
function repToAction(rep) {
|
|
352
|
+
const roots = buildRoots(rep);
|
|
353
|
+
const action = { verb: rep.verb, resource: rep.resource, params: { ...roots.action.params } };
|
|
354
|
+
if (rep.revClass !== undefined)
|
|
355
|
+
action.reversibilityClass = rep.revClass;
|
|
356
|
+
if (Object.keys(roots.subject).length > 0)
|
|
357
|
+
action.subject = roots.subject;
|
|
358
|
+
if (Object.keys(roots.env).length > 0)
|
|
359
|
+
action.env = roots.env;
|
|
360
|
+
return action;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Walk the representative grid, stopping at the first rep for which `predicate` is true. Returns
|
|
364
|
+
* `overflow: true` (without walking) when the grid is larger than `max`.
|
|
365
|
+
*/
|
|
366
|
+
function search(pr, predicate, max) {
|
|
367
|
+
if (productSize(pr) > max)
|
|
368
|
+
return { overflow: true };
|
|
369
|
+
for (const rep of representatives(pr)) {
|
|
370
|
+
if (predicate(rep))
|
|
371
|
+
return { hit: rep, overflow: false };
|
|
372
|
+
}
|
|
373
|
+
return { overflow: false };
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=enumerate.js.map
|