@atlasauth/pca-policy-bridge 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atlas Authorization
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # @atlasauth/pca-policy-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Import existing authorization policy into Proof-Carrying Authority (PCA). Each translator compiles a policy source into PCA predicates (the shapes `@atlasauth/pca` evaluates, droppable into an envelope's `predicates`) plus a report of what was and was not translated. Anything unmodeled fails closed: it never widens an allow.
4
+
5
+ Supported sources:
6
+
7
+ - **Cedar**: `permit` and `forbid` policies (`cedarToPca`)
8
+ - **OPA / Rego**: a subset covering `allow` rules written as conjunctions of `input` comparisons (`regoToPca`)
9
+ - **OpenFGA / Zanzibar**: ReBAC model plus relationship tuples (`openfgaToPca`)
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm i @atlasauth/pca-policy-bridge
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import { cedarToPca, decide } from '@atlasauth/pca-policy-bridge';
21
+
22
+ const result = cedarToPca(`
23
+ permit (principal == User::"alice", action == Action::"view", resource == Photo::"p1");
24
+ forbid (principal, action == Action::"delete", resource);
25
+ `);
26
+
27
+ result.report.errors; // non-empty => incomplete translation, treat as deny
28
+ result.report.skipped; // constructs that were not modeled (each fails closed)
29
+ result.predicates; // permits: plug into Envelope.predicates
30
+ result.denies; // forbids: applied with deny-overrides-permit via decide()
31
+
32
+ // Evaluate one action under the translation (deny overrides permit).
33
+ decide(result, {
34
+ subject: { id: 'User::alice' },
35
+ action: { verb: 'view', resource: 'Photo::p1' },
36
+ });
37
+ ```
38
+
39
+ `regoToPca(moduleText)` and `openfgaToPca(model, tuples)` return the same `BridgeResult` shape. For Cedar group membership (`principal in Group::"g"`), supply the entity parent map to the evaluator at `env.entity_parents`.
40
+
41
+ ## Boundaries
42
+
43
+ Each translator documents the subset it models. Not modeled (reported in `report.skipped` / `report.errors`, and failing closed): Cedar arithmetic, set operations and extension functions; Rego iteration, comprehensions, `:=` assignment and general builtins (the whole containing rule is dropped); OpenFGA userset-valued tuples such as `group:eng#member`.
44
+
45
+ ## API
46
+
47
+ `cedarToPca`, `regoToPca`, `openfgaToPca`, `decide`, and types `BridgeResult`, `TranslationReport`, `PolicySource`.
48
+
49
+ ## Status
50
+
51
+ Part of [Proof-Carrying Authority](https://github.com/Atlas-Authorization/pca). Policy translation is a static compile step; review the report before granting on its output. PCA's cryptography has not been independently audited.
52
+
53
+ ## License
54
+
55
+ MIT - see LICENSE
@@ -0,0 +1,59 @@
1
+ import type { BridgeResult } from './types';
2
+ /**
3
+ * Cedar → PCA predicate translator.
4
+ *
5
+ * SUPPORTED SUBSET (documented boundary — everything else fails closed)
6
+ * --------------------------------------------------------------------
7
+ * A policy set is `permit`/`forbid (principal, action, resource) [when {…}] [unless {…}] ;` units,
8
+ * separated by `;`. `//` line comments and `@annotation(...)` heads are stripped.
9
+ *
10
+ * Scope (the parenthesised head):
11
+ * - `principal` — unconstrained
12
+ * - `principal == User::"alice"` — subject.id == "User::alice"
13
+ * - `principal in Group::"g"` — member_of(subject.id, "Group::g") (entity hierarchy)
14
+ * - `principal is User` — is_a(subject.id, "User") (entity type test)
15
+ * - `action` — any verb
16
+ * - `action == Action::"view"` — verb = "view"
17
+ * - `action in [Action::"a", …]` — verb = ["a", …]
18
+ * - `resource` — any resource
19
+ * - `resource == Photo::"p1"` — resource = "Photo::p1"
20
+ * - `resource in Folder::"f"` — member_of(action.resource, "Folder::f")
21
+ * - `resource is Photo` — is_a(action.resource, "Photo")
22
+ *
23
+ * Conditions (inside `when` / `unless`): an arbitrary boolean expression over `&&`, `||` and
24
+ * parentheses whose atoms are:
25
+ * - `<lhs> == <rhs>` | `<lhs> != <rhs>` — eq / ne
26
+ * - `<lhs> < | <= | > | >= <rhs>` — lt / lte / gt / gte (attribute comparisons)
27
+ * - `<lhs> in [<lit>, …]` — in (membership in a LITERAL set)
28
+ * - `<lhs> in <Entity>` — member_of (entity hierarchy, bounded closure)
29
+ * - `<lhs> is <Type>` — is_a (entity type test)
30
+ * - `<lhs> like "<glob>"` — like (Cedar `*` wildcard; `\*` literal star)
31
+ * - `<lhs> has <attr>` — exists(<lhs>.<attr>)
32
+ * where <lhs>/<rhs> name:
33
+ * - `principal` → subject.id `principal.attr` → subject.attr
34
+ * - `resource` → action.resource `resource.attr` → env.resource.attr
35
+ * - `action` → action.verb
36
+ * - `context` / `context.x.y` → env / env.x.y
37
+ * A right-hand side that is itself one of those paths becomes a `ref`.
38
+ * Right-hand literals: "string", number, true/false, and `Type::"id"` (→ "Type::id").
39
+ *
40
+ * `||` → an `any_of` grouping; `&&` → an `all_of` grouping.
41
+ * `when { E }` → the translated E is ANDed into the predicate's `where`.
42
+ * `unless { E }` → `{ not: E }` is ANDed in (so a MULTI-condition `unless { A && B }` becomes
43
+ * `not(all_of[A,B])`, exact Cedar semantics).
44
+ *
45
+ * ENTITY HIERARCHY (`in` / member_of): the group/parent graph is supplied at evaluation time as a
46
+ * reflexive adjacency map at `env.entity_parents` — `{ "<child uid>": ["<parent uid>", …], … }`.
47
+ * `a in b` holds iff `b` is in the bounded, cycle-guarded transitive closure of `a` (an entity is in
48
+ * itself, matching Cedar). Supply the entities' `parents` closure under that key.
49
+ *
50
+ * Mapping of policy effect:
51
+ * - `permit` → a permit predicate (`result.predicates`)
52
+ * - `forbid` → a deny predicate (`result.denies`); consume with {@link decide} (deny-overrides).
53
+ *
54
+ * NOT MODELED (reported, fails closed) — the honest minimum:
55
+ * - arithmetic in a comparison (`resource.a + 1 < 10`), the set operations `.contains()` /
56
+ * `.containsAll()` / `.containsAny()`, `if-then-else`, record/set construction, and the
57
+ * extension functions (`ip(...)`, `decimal(...)`, `.isInRange(...)`, …).
58
+ */
59
+ export declare function cedarToPca(policyText: string): BridgeResult;
package/dist/cedar.js ADDED
@@ -0,0 +1,535 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cedarToPca = cedarToPca;
4
+ /**
5
+ * Cedar → PCA predicate translator.
6
+ *
7
+ * SUPPORTED SUBSET (documented boundary — everything else fails closed)
8
+ * --------------------------------------------------------------------
9
+ * A policy set is `permit`/`forbid (principal, action, resource) [when {…}] [unless {…}] ;` units,
10
+ * separated by `;`. `//` line comments and `@annotation(...)` heads are stripped.
11
+ *
12
+ * Scope (the parenthesised head):
13
+ * - `principal` — unconstrained
14
+ * - `principal == User::"alice"` — subject.id == "User::alice"
15
+ * - `principal in Group::"g"` — member_of(subject.id, "Group::g") (entity hierarchy)
16
+ * - `principal is User` — is_a(subject.id, "User") (entity type test)
17
+ * - `action` — any verb
18
+ * - `action == Action::"view"` — verb = "view"
19
+ * - `action in [Action::"a", …]` — verb = ["a", …]
20
+ * - `resource` — any resource
21
+ * - `resource == Photo::"p1"` — resource = "Photo::p1"
22
+ * - `resource in Folder::"f"` — member_of(action.resource, "Folder::f")
23
+ * - `resource is Photo` — is_a(action.resource, "Photo")
24
+ *
25
+ * Conditions (inside `when` / `unless`): an arbitrary boolean expression over `&&`, `||` and
26
+ * parentheses whose atoms are:
27
+ * - `<lhs> == <rhs>` | `<lhs> != <rhs>` — eq / ne
28
+ * - `<lhs> < | <= | > | >= <rhs>` — lt / lte / gt / gte (attribute comparisons)
29
+ * - `<lhs> in [<lit>, …]` — in (membership in a LITERAL set)
30
+ * - `<lhs> in <Entity>` — member_of (entity hierarchy, bounded closure)
31
+ * - `<lhs> is <Type>` — is_a (entity type test)
32
+ * - `<lhs> like "<glob>"` — like (Cedar `*` wildcard; `\*` literal star)
33
+ * - `<lhs> has <attr>` — exists(<lhs>.<attr>)
34
+ * where <lhs>/<rhs> name:
35
+ * - `principal` → subject.id `principal.attr` → subject.attr
36
+ * - `resource` → action.resource `resource.attr` → env.resource.attr
37
+ * - `action` → action.verb
38
+ * - `context` / `context.x.y` → env / env.x.y
39
+ * A right-hand side that is itself one of those paths becomes a `ref`.
40
+ * Right-hand literals: "string", number, true/false, and `Type::"id"` (→ "Type::id").
41
+ *
42
+ * `||` → an `any_of` grouping; `&&` → an `all_of` grouping.
43
+ * `when { E }` → the translated E is ANDed into the predicate's `where`.
44
+ * `unless { E }` → `{ not: E }` is ANDed in (so a MULTI-condition `unless { A && B }` becomes
45
+ * `not(all_of[A,B])`, exact Cedar semantics).
46
+ *
47
+ * ENTITY HIERARCHY (`in` / member_of): the group/parent graph is supplied at evaluation time as a
48
+ * reflexive adjacency map at `env.entity_parents` — `{ "<child uid>": ["<parent uid>", …], … }`.
49
+ * `a in b` holds iff `b` is in the bounded, cycle-guarded transitive closure of `a` (an entity is in
50
+ * itself, matching Cedar). Supply the entities' `parents` closure under that key.
51
+ *
52
+ * Mapping of policy effect:
53
+ * - `permit` → a permit predicate (`result.predicates`)
54
+ * - `forbid` → a deny predicate (`result.denies`); consume with {@link decide} (deny-overrides).
55
+ *
56
+ * NOT MODELED (reported, fails closed) — the honest minimum:
57
+ * - arithmetic in a comparison (`resource.a + 1 < 10`), the set operations `.contains()` /
58
+ * `.containsAll()` / `.containsAny()`, `if-then-else`, record/set construction, and the
59
+ * extension functions (`ip(...)`, `decimal(...)`, `.isInRange(...)`, …).
60
+ */
61
+ function cedarToPca(policyText) {
62
+ const report = { source: 'cedar', translated: [], skipped: [], errors: [] };
63
+ const predicates = [];
64
+ const denies = [];
65
+ const cleaned = stripComments(policyText);
66
+ const statements = splitTopLevel(cleaned, ';')
67
+ .map((s) => s.trim())
68
+ .filter((s) => s.length > 0);
69
+ for (const stmt of statements) {
70
+ try {
71
+ translateStatement(stmt, predicates, denies, report);
72
+ }
73
+ catch (e) {
74
+ report.errors.push(`policy "${truncate(stmt)}": ${e instanceof Error ? e.message : String(e)}`);
75
+ }
76
+ }
77
+ return { predicates, denies, caveats: [], report };
78
+ }
79
+ /** The reflexive adjacency map (child uid -> parent uid[]) that `in`/member_of resolves against. */
80
+ const ENTITY_PARENTS = 'env.entity_parents';
81
+ // --- statement ---------------------------------------------------------------------------------
82
+ function translateStatement(stmt, permits, denies, report) {
83
+ // Strip leading @annotation(...) heads.
84
+ let body = stmt;
85
+ const annot = /^\s*(@[A-Za-z_][\w]*\s*\([^)]*\)\s*)+/.exec(body);
86
+ if (annot)
87
+ body = body.slice(annot[0].length);
88
+ const head = /^\s*(permit|forbid)\s*\(/i.exec(body);
89
+ if (!head) {
90
+ report.skipped.push(`unrecognized statement: "${truncate(stmt)}"`);
91
+ return;
92
+ }
93
+ const effect = head[1]?.toLowerCase() === 'forbid' ? 'forbid' : 'permit';
94
+ const openIdx = body.indexOf('(', head.index);
95
+ const closeIdx = matchParen(body, openIdx);
96
+ if (closeIdx < 0)
97
+ throw new Error('unterminated scope parentheses');
98
+ const scopeText = body.slice(openIdx + 1, closeIdx);
99
+ const rest = body.slice(closeIdx + 1);
100
+ const scope = parseScope(scopeText);
101
+ const where = [...scope.scopeConditions];
102
+ // Collect when / unless blocks.
103
+ const blocks = parseConditionBlocks(rest);
104
+ for (const block of blocks) {
105
+ const parsed = parseExpr(block.text);
106
+ if (parsed === null) {
107
+ report.skipped.push(`unmodeled ${block.kind}-condition "${truncate(block.text)}" — policy dropped (fail closed)`);
108
+ dropPolicy(effect, stmt, report);
109
+ return;
110
+ }
111
+ where.push(block.kind === 'when' ? parsed : { not: parsed });
112
+ }
113
+ const predicate = { verb: scope.verb };
114
+ if (scope.resource !== undefined)
115
+ predicate.resource = scope.resource;
116
+ if (where.length > 0)
117
+ predicate.where = where;
118
+ if (effect === 'permit') {
119
+ permits.push(predicate);
120
+ report.translated.push(`permit → verb ${JSON.stringify(scope.verb)}${scope.resource ? ` on ${scope.resource}` : ''} (${where.length} condition(s))`);
121
+ }
122
+ else {
123
+ denies.push(predicate);
124
+ report.translated.push(`forbid → deny verb ${JSON.stringify(scope.verb)}${scope.resource ? ` on ${scope.resource}` : ''} (${where.length} condition(s))`);
125
+ }
126
+ }
127
+ /** A dropped `permit` simply grants nothing; a dropped `forbid` is unsafe, so it is an ERROR. */
128
+ function dropPolicy(effect, stmt, report) {
129
+ if (effect === 'forbid') {
130
+ report.errors.push(`forbid could not be fully modeled and was dropped — treat result as fail-closed: "${truncate(stmt)}"`);
131
+ }
132
+ }
133
+ function parseScope(scopeText) {
134
+ const clauses = splitTopLevel(scopeText, ',')
135
+ .map((c) => c.trim())
136
+ .filter((c) => c.length > 0);
137
+ const result = { verb: '*', scopeConditions: [] };
138
+ for (const clause of clauses) {
139
+ const m = /^(principal|action|resource)\b\s*(==|in|is)?\s*([\s\S]*)$/.exec(clause);
140
+ if (!m)
141
+ throw new Error(`unparseable scope clause "${truncate(clause)}"`);
142
+ const variable = m[1];
143
+ const op = m[2];
144
+ const operand = (m[3] ?? '').trim();
145
+ if (!op)
146
+ continue; // unconstrained (bare principal/action/resource)
147
+ if (variable === 'action') {
148
+ if (op === '==') {
149
+ const id = entityId(operand);
150
+ if (id === null)
151
+ throw new Error(`action == expects an Action entity, got "${truncate(operand)}"`);
152
+ result.verb = id;
153
+ }
154
+ else if (op === 'in' && operand.startsWith('[')) {
155
+ const items = splitTopLevel(operand.slice(1, -1), ',')
156
+ .map((x) => x.trim())
157
+ .filter((x) => x.length > 0);
158
+ const verbs = [];
159
+ for (const it of items) {
160
+ const id = entityId(it);
161
+ if (id === null)
162
+ throw new Error(`action in [...] expects Action entities, got "${truncate(it)}"`);
163
+ verbs.push(id);
164
+ }
165
+ result.verb = verbs;
166
+ }
167
+ else {
168
+ throw new Error(`unsupported action scope operator "${op}"`);
169
+ }
170
+ }
171
+ else {
172
+ const field = variable === 'resource' ? 'action.resource' : 'subject.id';
173
+ if (op === '==') {
174
+ if (variable === 'resource') {
175
+ const eid = entityUid(operand);
176
+ if (eid === null)
177
+ throw new Error(`resource == expects an entity, got "${truncate(operand)}"`);
178
+ result.resource = eid;
179
+ }
180
+ else {
181
+ const eid = entityUid(operand);
182
+ if (eid === null)
183
+ throw new Error(`principal == expects an entity, got "${truncate(operand)}"`);
184
+ result.scopeConditions.push({ field, op: 'eq', value: eid });
185
+ }
186
+ }
187
+ else if (op === 'is') {
188
+ const type = typeName(operand);
189
+ if (type === null)
190
+ throw new Error(`${variable} is expects a type, got "${truncate(operand)}"`);
191
+ result.scopeConditions.push({ field, op: 'is_a', value: type });
192
+ }
193
+ else {
194
+ // op === 'in' : entity-hierarchy membership
195
+ const mem = memberOfCondition(field, operand);
196
+ if (mem === null)
197
+ throw new Error(`${variable} in expects an entity (hierarchy), got "${truncate(operand)}"`);
198
+ result.scopeConditions.push(mem);
199
+ }
200
+ }
201
+ }
202
+ return result;
203
+ }
204
+ function parseConditionBlocks(rest) {
205
+ const blocks = [];
206
+ const re = /\b(when|unless)\b\s*\{/g;
207
+ let m;
208
+ while ((m = re.exec(rest)) !== null) {
209
+ const kind = m[1] === 'unless' ? 'unless' : 'when';
210
+ const open = rest.indexOf('{', m.index);
211
+ const close = matchBrace(rest, open);
212
+ if (close < 0)
213
+ throw new Error(`unterminated ${kind} block`);
214
+ blocks.push({ kind, text: rest.slice(open + 1, close) });
215
+ re.lastIndex = close + 1;
216
+ }
217
+ return blocks;
218
+ }
219
+ /**
220
+ * Parse a boolean expression: `||` (lowest precedence, → any_of) over `&&` (→ all_of) over
221
+ * parenthesised groups over single comparison atoms. Returns null if any atom is outside the subset.
222
+ */
223
+ function parseExpr(text) {
224
+ const t = text.trim();
225
+ if (t.length === 0)
226
+ return null;
227
+ const ors = splitTopLevel(t, '||')
228
+ .map((s) => s.trim())
229
+ .filter((s) => s.length > 0);
230
+ if (ors.length > 1) {
231
+ const subs = ors.map(parseExpr);
232
+ if (subs.some((s) => s === null))
233
+ return null;
234
+ return { any_of: subs };
235
+ }
236
+ const ands = splitTopLevel(t, '&&')
237
+ .map((s) => s.trim())
238
+ .filter((s) => s.length > 0);
239
+ if (ands.length > 1) {
240
+ const subs = ands.map(parseExpr);
241
+ if (subs.some((s) => s === null))
242
+ return null;
243
+ return { all_of: subs };
244
+ }
245
+ // Single term: strip a fully-enclosing parenthesis, else parse one atom.
246
+ if (t.startsWith('(') && matchParen(t, 0) === t.length - 1)
247
+ return parseExpr(t.slice(1, -1));
248
+ return parseAtom(t);
249
+ }
250
+ /** Parse one comparison atom → a leaf Condition, or null if outside the subset. */
251
+ function parseAtom(text) {
252
+ const t = text.trim();
253
+ // Symbolic comparison operators, longest token first (so `<=`/`>=`/`==`/`!=` win over `<`/`>`).
254
+ for (const [tok, op] of [
255
+ ['==', 'eq'],
256
+ ['!=', 'ne'],
257
+ ['<=', 'lte'],
258
+ ['>=', 'gte'],
259
+ ['<', 'lt'],
260
+ ['>', 'gt'],
261
+ ]) {
262
+ const idx = indexOfTopLevel(t, tok);
263
+ if (idx >= 0) {
264
+ const lhs = t.slice(0, idx).trim();
265
+ const rhs = t.slice(idx + tok.length).trim();
266
+ return buildComparison(lhs, op, rhs);
267
+ }
268
+ }
269
+ // Word operators: `in`, `is`, `like`, `has`.
270
+ const wm = /^([\s\S]+?)\s+(in|is|like|has)\s+([\s\S]+)$/.exec(t);
271
+ if (wm)
272
+ return buildWordOp((wm[1] ?? '').trim(), wm[2] ?? '', (wm[3] ?? '').trim());
273
+ // A bare expression (`resource.flag`, `principal.foo.contains("x")`, …) is not modeled.
274
+ return null;
275
+ }
276
+ function buildComparison(lhsRaw, op, rhsRaw) {
277
+ const field = toFieldPath(lhsRaw);
278
+ if (field === null)
279
+ return null; // arithmetic / method-call LHS not modeled
280
+ if (isPathExpr(rhsRaw)) {
281
+ const refPath = toFieldPath(rhsRaw);
282
+ if (refPath !== null)
283
+ return { field, op, ref: refPath };
284
+ }
285
+ const lit = toLiteral(rhsRaw);
286
+ if (!lit.ok)
287
+ return null;
288
+ return { field, op, value: lit.value };
289
+ }
290
+ function buildWordOp(lhsRaw, kind, rhsRaw) {
291
+ if (kind === 'has') {
292
+ // `principal has department` → exists(subject.department)
293
+ if (!/^[A-Za-z_][\w]*$/.test(rhsRaw))
294
+ return null;
295
+ const field = toFieldPath(`${lhsRaw}.${rhsRaw}`);
296
+ return field === null ? null : { field, op: 'exists' };
297
+ }
298
+ const field = toFieldPath(lhsRaw);
299
+ if (field === null)
300
+ return null;
301
+ if (kind === 'is') {
302
+ const type = typeName(rhsRaw);
303
+ return type === null ? null : { field, op: 'is_a', value: type };
304
+ }
305
+ if (kind === 'like') {
306
+ const str = /^"([\s\S]*)"$/.exec(rhsRaw);
307
+ if (!str)
308
+ return null;
309
+ return { field, op: 'like', value: cedarLikeToGlob(str[1] ?? '') };
310
+ }
311
+ // kind === 'in'
312
+ if (rhsRaw.startsWith('[')) {
313
+ // Membership in a LITERAL set.
314
+ const inner = rhsRaw.endsWith(']') ? rhsRaw.slice(1, -1) : rhsRaw.slice(1);
315
+ const items = splitTopLevel(inner, ',')
316
+ .map((x) => x.trim())
317
+ .filter((x) => x.length > 0);
318
+ // A set of entities (`[Group::"a", Group::"b"]`) is still a hierarchy membership (any-of).
319
+ if (items.every((it) => entityUid(it) !== null) && items.length > 0) {
320
+ const uids = items.map((it) => entityUid(it));
321
+ return { field, op: 'member_of', value: uids, collection: ENTITY_PARENTS };
322
+ }
323
+ const values = [];
324
+ for (const it of items) {
325
+ const lit = toLiteral(it);
326
+ if (!lit.ok)
327
+ return null;
328
+ values.push(lit.value);
329
+ }
330
+ return { field, op: 'in', value: values };
331
+ }
332
+ // `lhs in Entity` → hierarchy membership (bounded closure).
333
+ return memberOfCondition(field, rhsRaw);
334
+ }
335
+ /** A `member_of` condition for `<field> in <entity|path>` (entity literal → value; path → ref). */
336
+ function memberOfCondition(field, operand) {
337
+ const eid = entityUid(operand);
338
+ if (eid !== null)
339
+ return { field, op: 'member_of', value: eid, collection: ENTITY_PARENTS };
340
+ if (isPathExpr(operand)) {
341
+ const ref = toFieldPath(operand);
342
+ if (ref !== null)
343
+ return { field, op: 'member_of', ref, collection: ENTITY_PARENTS };
344
+ }
345
+ return null;
346
+ }
347
+ /** Does an expression refer to a Cedar scope variable (principal/resource/action/context)? */
348
+ function isPathExpr(expr) {
349
+ return /^(principal|resource|action|context)\b/.test(expr);
350
+ }
351
+ /** Map a Cedar scope-variable expression to a PCA dotted field path; null if not a path. */
352
+ function toFieldPath(expr) {
353
+ if (expr === 'principal')
354
+ return 'subject.id';
355
+ if (expr === 'resource')
356
+ return 'action.resource';
357
+ if (expr === 'action')
358
+ return 'action.verb';
359
+ if (expr === 'context')
360
+ return 'env';
361
+ const mp = /^principal\.([A-Za-z_][\w.]*)$/.exec(expr);
362
+ if (mp)
363
+ return `subject.${mp[1]}`;
364
+ const mr = /^resource\.([A-Za-z_][\w.]*)$/.exec(expr);
365
+ if (mr)
366
+ return `env.resource.${mr[1]}`;
367
+ const mc = /^context\.([A-Za-z_][\w.]*)$/.exec(expr);
368
+ if (mc)
369
+ return `env.${mc[1]}`;
370
+ return null;
371
+ }
372
+ // --- literals / entities -----------------------------------------------------------------------
373
+ /** A Cedar type name (`User`, `Ns::User`), or null. */
374
+ function typeName(expr) {
375
+ const s = expr.trim();
376
+ return /^[A-Za-z_][\w]*(::[A-Za-z_][\w]*)*$/.test(s) ? s : null;
377
+ }
378
+ /** The quoted id of an entity reference `Ns::Type::"id"`, or null. */
379
+ function entityId(expr) {
380
+ const m = /^.+::"([^"]*)"$/.exec(expr.trim());
381
+ return m ? (m[1] ?? '') : null;
382
+ }
383
+ /** An entity reference rendered as its UID with quotes dropped: `Type::"id"` → `Type::id`. */
384
+ function entityUid(expr) {
385
+ const m = /^(.+)::"([^"]*)"$/.exec(expr.trim());
386
+ if (!m)
387
+ return null;
388
+ return `${m[1]}::${m[2] ?? ''}`;
389
+ }
390
+ function toLiteral(expr) {
391
+ const s = expr.trim();
392
+ const str = /^"([^"]*)"$/.exec(s);
393
+ if (str)
394
+ return { ok: true, value: str[1] ?? '' };
395
+ if (s === 'true')
396
+ return { ok: true, value: true };
397
+ if (s === 'false')
398
+ return { ok: true, value: false };
399
+ if (/^-?\d+(\.\d+)?$/.test(s))
400
+ return { ok: true, value: Number(s) };
401
+ const eid = entityUid(s);
402
+ if (eid !== null)
403
+ return { ok: true, value: eid };
404
+ return { ok: false };
405
+ }
406
+ /**
407
+ * Convert a Cedar `like` pattern (only `*` is a wildcard; `\*` is a literal star) to the PCA glob the
408
+ * `like` op understands (`*`/`?` wildcards, `\` escapes). Cedar's literal `?` and `\` are escaped so
409
+ * they stay literal.
410
+ */
411
+ function cedarLikeToGlob(inner) {
412
+ let out = '';
413
+ for (let i = 0; i < inner.length; i++) {
414
+ const ch = inner[i];
415
+ if (ch === '\\') {
416
+ const n = inner[i + 1];
417
+ if (n === '*') {
418
+ out += '\\*'; // Cedar literal star
419
+ i++;
420
+ continue;
421
+ }
422
+ if (n !== undefined) {
423
+ out += escapeGlobLiteral(n);
424
+ i++;
425
+ continue;
426
+ }
427
+ out += '\\\\';
428
+ continue;
429
+ }
430
+ if (ch === '*') {
431
+ out += '*'; // wildcard
432
+ continue;
433
+ }
434
+ out += escapeGlobLiteral(ch);
435
+ }
436
+ return out;
437
+ }
438
+ function escapeGlobLiteral(ch) {
439
+ return ch === '*' || ch === '?' || ch === '\\' ? '\\' + ch : ch;
440
+ }
441
+ // --- text helpers ------------------------------------------------------------------------------
442
+ function stripComments(text) {
443
+ return text.replace(/\/\/[^\n]*/g, '');
444
+ }
445
+ function truncate(s) {
446
+ const one = s.replace(/\s+/g, ' ').trim();
447
+ return one.length > 80 ? `${one.slice(0, 77)}...` : one;
448
+ }
449
+ /** Index of `token` at bracket/quote depth 0, or -1. */
450
+ function indexOfTopLevel(text, token) {
451
+ let depth = 0;
452
+ let inStr = false;
453
+ for (let i = 0; i < text.length; i++) {
454
+ const ch = text[i] ?? '';
455
+ if (inStr) {
456
+ if (ch === '"')
457
+ inStr = false;
458
+ continue;
459
+ }
460
+ if (ch === '"') {
461
+ inStr = true;
462
+ continue;
463
+ }
464
+ if (ch === '(' || ch === '[' || ch === '{')
465
+ depth++;
466
+ else if (ch === ')' || ch === ']' || ch === '}')
467
+ depth--;
468
+ else if (depth === 0 && text.startsWith(token, i))
469
+ return i;
470
+ }
471
+ return -1;
472
+ }
473
+ /** Split on a delimiter at bracket/quote depth 0. `delim` is 1 or 2 chars. */
474
+ function splitTopLevel(text, delim) {
475
+ const out = [];
476
+ let depth = 0;
477
+ let inStr = false;
478
+ let buf = '';
479
+ for (let i = 0; i < text.length; i++) {
480
+ const ch = text[i] ?? '';
481
+ if (inStr) {
482
+ buf += ch;
483
+ if (ch === '"')
484
+ inStr = false;
485
+ continue;
486
+ }
487
+ if (ch === '"') {
488
+ inStr = true;
489
+ buf += ch;
490
+ continue;
491
+ }
492
+ if (ch === '(' || ch === '[' || ch === '{')
493
+ depth++;
494
+ else if (ch === ')' || ch === ']' || ch === '}')
495
+ depth--;
496
+ if (depth === 0 && text.startsWith(delim, i)) {
497
+ out.push(buf);
498
+ buf = '';
499
+ i += delim.length - 1;
500
+ continue;
501
+ }
502
+ buf += ch;
503
+ }
504
+ out.push(buf);
505
+ return out;
506
+ }
507
+ function matchParen(text, open) {
508
+ return matchDelim(text, open, '(', ')');
509
+ }
510
+ function matchBrace(text, open) {
511
+ return matchDelim(text, open, '{', '}');
512
+ }
513
+ function matchDelim(text, open, o, c) {
514
+ let depth = 0;
515
+ let inStr = false;
516
+ for (let i = open; i < text.length; i++) {
517
+ const ch = text[i] ?? '';
518
+ if (inStr) {
519
+ if (ch === '"')
520
+ inStr = false;
521
+ continue;
522
+ }
523
+ if (ch === '"')
524
+ inStr = true;
525
+ else if (ch === o)
526
+ depth++;
527
+ else if (ch === c) {
528
+ depth--;
529
+ if (depth === 0)
530
+ return i;
531
+ }
532
+ }
533
+ return -1;
534
+ }
535
+ //# sourceMappingURL=cedar.js.map