@highflame/policy 1.1.3 → 1.2.1

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.
Files changed (55) hide show
  1. package/dist/actions.gen.d.ts +21 -0
  2. package/dist/actions.gen.d.ts.map +1 -1
  3. package/dist/actions.gen.js +21 -0
  4. package/dist/actions.gen.js.map +1 -1
  5. package/dist/builder.d.ts +47 -10
  6. package/dist/builder.d.ts.map +1 -1
  7. package/dist/builder.js.map +1 -1
  8. package/dist/engine.d.ts +37 -0
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +99 -0
  11. package/dist/engine.js.map +1 -1
  12. package/dist/engine.test.d.ts +8 -0
  13. package/dist/engine.test.d.ts.map +1 -0
  14. package/dist/engine.test.js +190 -0
  15. package/dist/engine.test.js.map +1 -0
  16. package/dist/entities.gen.d.ts +4 -0
  17. package/dist/entities.gen.d.ts.map +1 -1
  18. package/dist/entities.gen.js +4 -0
  19. package/dist/entities.gen.js.map +1 -1
  20. package/dist/errors.d.ts +102 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +127 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/parser.d.ts +34 -0
  29. package/dist/parser.d.ts.map +1 -0
  30. package/dist/parser.js +393 -0
  31. package/dist/parser.js.map +1 -0
  32. package/dist/parser.test.d.ts +8 -0
  33. package/dist/parser.test.d.ts.map +1 -0
  34. package/dist/parser.test.js +143 -0
  35. package/dist/parser.test.js.map +1 -0
  36. package/dist/schema.gen.d.ts +1 -1
  37. package/dist/schema.gen.d.ts.map +1 -1
  38. package/dist/schema.gen.js +331 -17
  39. package/dist/schema.gen.js.map +1 -1
  40. package/dist/types.d.ts +1 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js +2 -0
  43. package/dist/types.js.map +1 -1
  44. package/package.json +8 -2
  45. package/src/actions.gen.ts +21 -0
  46. package/src/builder.ts +52 -10
  47. package/src/engine.test.ts +371 -0
  48. package/src/engine.ts +145 -0
  49. package/src/entities.gen.ts +4 -0
  50. package/src/errors.ts +195 -0
  51. package/src/index.ts +2 -0
  52. package/src/parser.test.ts +169 -0
  53. package/src/parser.ts +517 -0
  54. package/src/schema.gen.ts +331 -17
  55. package/src/types.ts +3 -0
package/src/parser.ts ADDED
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Cedar Policy Parser
3
+ *
4
+ * Converts Cedar policy text to structured PolicyRule format using the
5
+ * official Cedar engine (cedar-wasm) for parsing.
6
+ *
7
+ * Architecture:
8
+ * 1. Cedar text → Cedar JSON (via cedar-wasm policyToJson)
9
+ * 2. Cedar JSON → PolicyRule (simple JSON mapping)
10
+ */
11
+
12
+ import * as cedar from "@cedar-policy/cedar-wasm/nodejs";
13
+ import type { PolicyRule, PolicyCondition, PolicyEntity, PolicyEffect, ConditionOperator } from "./builder.js";
14
+ import { ParserError, ErrorCodes } from "./errors.js";
15
+
16
+ /**
17
+ * Result of parsing Cedar policies
18
+ */
19
+ export interface ParseResult {
20
+ /** Policies successfully converted to PolicyRule format */
21
+ rules: PolicyRule[];
22
+ /** Policies that couldn't be fully represented as PolicyRule (raw Cedar text) */
23
+ unstructured: string[];
24
+ /** Any parsing errors encountered */
25
+ errors: string[];
26
+ }
27
+
28
+ /**
29
+ * Cedar's JSON policy format (from cedar-wasm)
30
+ * @see https://docs.cedarpolicy.com/policies/json-format.html
31
+ */
32
+ interface CedarPolicyJSON {
33
+ effect: "permit" | "forbid";
34
+ principal: CedarScopeConstraint;
35
+ action: CedarActionConstraint;
36
+ resource: CedarScopeConstraint;
37
+ conditions: CedarCondition[];
38
+ annotations?: Record<string, string>;
39
+ }
40
+
41
+ // Principal/Resource constraint types
42
+ type CedarScopeConstraint =
43
+ | { op: "All" }
44
+ | { op: "=="; entity: CedarEntityRef }
45
+ | { op: "=="; slot: string }
46
+ | { op: "in"; entity: CedarEntityRef }
47
+ | { op: "in"; slot: string }
48
+ | { op: "is"; entity_type: string; in?: { entity: CedarEntityRef } | { slot: string } };
49
+
50
+ // Action constraint types (slightly different from principal/resource)
51
+ type CedarActionConstraint =
52
+ | { op: "All" }
53
+ | { op: "=="; entity: CedarEntityRef }
54
+ | { op: "in"; entity: CedarEntityRef }
55
+ | { op: "in"; entities: CedarEntityRef[] };
56
+
57
+ // Entity UID can be { type, id } or { __entity: { type, id } }
58
+ type CedarEntityRef =
59
+ | { type: string; id: string }
60
+ | { __entity: { type: string; id: string } };
61
+
62
+ interface CedarCondition {
63
+ kind: "when" | "unless";
64
+ body: Record<string, unknown>;
65
+ }
66
+
67
+ /**
68
+ * Normalize entity reference to simple { type, id } format
69
+ */
70
+ function normalizeEntityRef(ref: CedarEntityRef): { type: string; id: string } {
71
+ if ("__entity" in ref) {
72
+ return ref.__entity;
73
+ }
74
+ return ref;
75
+ }
76
+
77
+ /**
78
+ * Parse Cedar policy text and convert to PolicyRule format.
79
+ *
80
+ * Uses the official cedar-wasm engine for parsing, ensuring correctness.
81
+ * Policies with features that can't be represented as PolicyRule (e.g.,
82
+ * unless clauses, complex expressions) are returned in the unstructured array.
83
+ *
84
+ * @param cedarText - Cedar policy text to parse
85
+ * @returns ParseResult with structured rules, unstructured policies, and errors
86
+ */
87
+ export function parseCedarToRules(cedarText: string): ParseResult {
88
+ const result: ParseResult = {
89
+ rules: [],
90
+ unstructured: [],
91
+ errors: [],
92
+ };
93
+
94
+ try {
95
+ // Split the policy set into individual policies and templates
96
+ const partsResult = cedar.policySetTextToParts(cedarText);
97
+
98
+ if (partsResult.type === "failure") {
99
+ for (const error of partsResult.errors) {
100
+ result.errors.push(error.message);
101
+ }
102
+ return result;
103
+ }
104
+
105
+ // Process each policy
106
+ let index = 0;
107
+ for (const policyText of partsResult.policies) {
108
+ // Convert individual policy to JSON using cedar-wasm
109
+ const jsonResult = cedar.policyToJson(policyText);
110
+
111
+ if (jsonResult.type === "failure") {
112
+ for (const error of jsonResult.errors) {
113
+ result.errors.push(`Policy ${index}: ${error.message}`);
114
+ }
115
+ index++;
116
+ continue;
117
+ }
118
+
119
+ const policy = jsonResult.json as CedarPolicyJSON;
120
+ const policyId = policy.annotations?.id || `policy${index}`;
121
+ const conversion = cedarJsonToRule(policy, policyId, index, policyText);
122
+
123
+ if (conversion.error) {
124
+ result.errors.push(`Policy ${policyId}: ${conversion.error}`);
125
+ }
126
+
127
+ if (conversion.rule) {
128
+ result.rules.push(conversion.rule);
129
+ } else if (conversion.raw) {
130
+ result.unstructured.push(conversion.raw);
131
+ }
132
+
133
+ index++;
134
+ }
135
+
136
+ // Templates can't be represented as PolicyRule
137
+ for (const templateText of partsResult.policy_templates) {
138
+ result.unstructured.push(templateText);
139
+ }
140
+
141
+ } catch (e) {
142
+ result.errors.push(`Parse error: ${e instanceof Error ? e.message : String(e)}`);
143
+ }
144
+
145
+ // Check for duplicate policy IDs and add warnings
146
+ const idOccurrences = new Map<string, number[]>();
147
+ result.rules.forEach((rule, idx) => {
148
+ if (rule.id) {
149
+ const indices = idOccurrences.get(rule.id) || [];
150
+ indices.push(idx);
151
+ idOccurrences.set(rule.id, indices);
152
+ }
153
+ });
154
+ for (const [id, indices] of idOccurrences) {
155
+ if (indices.length > 1) {
156
+ result.errors.push(
157
+ `[${ErrorCodes.PARSE_DUPLICATE_ID}] Duplicate policy ID '${id}' found at indices [${indices.join(", ")}]`
158
+ );
159
+ }
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Convert Cedar JSON policy to PolicyRule.
167
+ * This is pure JSON mapping - no parsing logic.
168
+ */
169
+ function cedarJsonToRule(
170
+ policy: CedarPolicyJSON,
171
+ policyId: string,
172
+ index: number,
173
+ originalText?: string
174
+ ): { rule?: PolicyRule; raw?: string; error?: string } {
175
+
176
+ // Check if this policy can be represented as PolicyRule
177
+ if (!canRepresentAsRule(policy)) {
178
+ // Return original text if available, otherwise convert back from JSON
179
+ const raw = originalText || getRawCedar(policyId, policy);
180
+ return { raw };
181
+ }
182
+
183
+ try {
184
+ const rule: PolicyRule = {
185
+ id: policy.annotations?.id || policyId,
186
+ name: policy.annotations?.name || policy.annotations?.id || policyId,
187
+ effect: policy.effect as PolicyEffect,
188
+ principal: mapScopeToEntity(policy.principal, "principal"),
189
+ action: mapActionScope(policy.action),
190
+ resource: mapScopeToEntity(policy.resource, "resource"),
191
+ conditions: [],
192
+ enabled: true,
193
+ order: index,
194
+ };
195
+
196
+ // Map description from annotations
197
+ if (policy.annotations?.description) {
198
+ rule.description = policy.annotations.description;
199
+ }
200
+
201
+ // Map conditions
202
+ const { conditions, rawCondition } = mapConditions(policy.conditions);
203
+ rule.conditions = conditions;
204
+ if (rawCondition) {
205
+ rule.rawCondition = rawCondition;
206
+ }
207
+
208
+ return { rule };
209
+ } catch (e) {
210
+ const error = e instanceof Error ? e.message : String(e);
211
+ const raw = originalText || getRawCedar(policyId, policy);
212
+ return { raw, error };
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Check if a Cedar policy can be represented as PolicyRule
218
+ */
219
+ function canRepresentAsRule(policy: CedarPolicyJSON): boolean {
220
+ // Unless clauses can't be represented
221
+ for (const cond of policy.conditions) {
222
+ if (cond.kind === "unless") {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ // Template slots can't be represented
228
+ if (hasSlot(policy.principal) || hasSlot(policy.resource)) {
229
+ return false;
230
+ }
231
+
232
+ // Multiple entity "in" constraints are complex
233
+ const action = policy.action;
234
+ if (action.op === "in" && "entities" in action && action.entities.length > 1) {
235
+ // Multiple actions are OK, we handle those
236
+ }
237
+
238
+ return true;
239
+ }
240
+
241
+ /**
242
+ * Check if a scope constraint uses a slot (template)
243
+ */
244
+ function hasSlot(scope: CedarScopeConstraint): boolean {
245
+ if (scope.op === "All" || scope.op === "is") {
246
+ return false;
247
+ }
248
+ return "slot" in scope;
249
+ }
250
+
251
+ /**
252
+ * Get raw Cedar text for a policy that can't be represented as PolicyRule
253
+ */
254
+ function getRawCedar(policyId: string, policy: CedarPolicyJSON): string {
255
+ try {
256
+ // policyToText accepts Policy which is string | PolicyJson
257
+ const textResult = cedar.policyToText(policy as cedar.PolicyJson);
258
+ if (textResult.type === "success") {
259
+ return textResult.text;
260
+ }
261
+ } catch {
262
+ // Ignore conversion errors
263
+ }
264
+ // Sanitize policyId to prevent newline injection attacks
265
+ const safeId = policyId.replace(/[\n\r]/g, " ");
266
+ return `// Complex policy: ${safeId}`;
267
+ }
268
+
269
+ /**
270
+ * Map Cedar scope constraint to PolicyEntity
271
+ *
272
+ * Returns null for unconstrained ("All") scopes.
273
+ * Throws ParserError for malformed constraints to prevent silent misinterpretation.
274
+ *
275
+ * @param scope - The Cedar scope constraint
276
+ * @param field - The field name ("principal" or "resource") for error context
277
+ */
278
+ function mapScopeToEntity(scope: CedarScopeConstraint, field: string): PolicyEntity | null {
279
+ if (scope.op === "All") {
280
+ return null;
281
+ }
282
+
283
+ if (scope.op === "==") {
284
+ if ("entity" in scope) {
285
+ const entity = normalizeEntityRef(scope.entity);
286
+ return { type: entity.type, id: entity.id };
287
+ }
288
+ if ("slot" in scope) {
289
+ throw ParserError.scopeSlotNotSupported("==", field);
290
+ }
291
+ throw ParserError.scopeMissingEntity("==", field);
292
+ }
293
+
294
+ if (scope.op === "is") {
295
+ if (scope.entity_type) {
296
+ return { type: scope.entity_type };
297
+ }
298
+ throw ParserError.scopeMissingEntityType(field);
299
+ }
300
+
301
+ if (scope.op === "in") {
302
+ if ("entity" in scope) {
303
+ const entity = normalizeEntityRef(scope.entity);
304
+ return { type: entity.type, id: entity.id };
305
+ }
306
+ if ("slot" in scope) {
307
+ throw ParserError.scopeSlotNotSupported("in", field);
308
+ }
309
+ throw ParserError.scopeMissingEntityList(field);
310
+ }
311
+
312
+ throw ParserError.scopeUnsupportedOp((scope as { op: string }).op, field);
313
+ }
314
+
315
+ /**
316
+ * Map action scope to action string(s)
317
+ *
318
+ * Throws ParserError for malformed constraints to prevent silent misinterpretation.
319
+ */
320
+ function mapActionScope(scope: CedarActionConstraint): string | string[] {
321
+ if (scope.op === "All") {
322
+ return "*";
323
+ }
324
+
325
+ if (scope.op === "==") {
326
+ if ("entity" in scope) {
327
+ const entity = normalizeEntityRef(scope.entity);
328
+ return entity.id;
329
+ }
330
+ throw ParserError.actionMissingEntity("==");
331
+ }
332
+
333
+ if (scope.op === "in") {
334
+ if ("entities" in scope) {
335
+ const actions = scope.entities.map(e => normalizeEntityRef(e).id);
336
+ return actions.length === 1 ? actions[0] : actions;
337
+ }
338
+ if ("entity" in scope) {
339
+ const entity = normalizeEntityRef(scope.entity);
340
+ return entity.id;
341
+ }
342
+ throw ParserError.actionMissingEntities();
343
+ }
344
+
345
+ throw ParserError.actionUnsupportedOp((scope as { op: string }).op);
346
+ }
347
+
348
+ /**
349
+ * Map Cedar conditions to PolicyCondition array
350
+ */
351
+ function mapConditions(conditions: CedarCondition[]): {
352
+ conditions: PolicyCondition[];
353
+ rawCondition?: string;
354
+ } {
355
+ const result: PolicyCondition[] = [];
356
+ const rawParts: string[] = [];
357
+
358
+ for (const cond of conditions) {
359
+ if (cond.kind !== "when") {
360
+ continue;
361
+ }
362
+
363
+ const parsed = mapConditionBody(cond.body);
364
+ if (parsed.condition) {
365
+ result.push(parsed.condition);
366
+ } else if (parsed.raw) {
367
+ rawParts.push(parsed.raw);
368
+ }
369
+ }
370
+
371
+ // Store raw conditions as a valid JSON array instead of joining with " && "
372
+ // This ensures downstream systems can parse the rawCondition field
373
+ return {
374
+ conditions: result,
375
+ rawCondition: rawParts.length > 0 ? `[${rawParts.join(",")}]` : undefined,
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Cedar expression types (subset used for condition mapping)
381
+ */
382
+ interface CedarExpr {
383
+ "."?: { left: CedarExpr; attr: string };
384
+ Var?: string;
385
+ Value?: unknown;
386
+ "=="?: { left: CedarExpr; right: CedarExpr };
387
+ "!="?: { left: CedarExpr; right: CedarExpr };
388
+ "<"?: { left: CedarExpr; right: CedarExpr };
389
+ "<="?: { left: CedarExpr; right: CedarExpr };
390
+ ">"?: { left: CedarExpr; right: CedarExpr };
391
+ ">="?: { left: CedarExpr; right: CedarExpr };
392
+ contains?: { left: CedarExpr; right: CedarExpr };
393
+ like?: { left: CedarExpr; pattern: Array<"Wildcard" | { Literal: string }> };
394
+ }
395
+
396
+ /**
397
+ * Map a Cedar expression body to PolicyCondition
398
+ *
399
+ * Cedar JSON expressions use nested objects with operator keys.
400
+ * Comparison format: { "==": { left: { ".": { left: { Var: "context" }, attr: "field" } }, right: { Value: "x" } } }
401
+ */
402
+ function mapConditionBody(body: Record<string, unknown>): {
403
+ condition?: PolicyCondition;
404
+ raw?: string;
405
+ } {
406
+ const expr = body as CedarExpr;
407
+
408
+ // Check comparison operators
409
+ for (const op of ["==", "!=", "<", "<=", ">", ">="] as const) {
410
+ const comparison = expr[op];
411
+ if (comparison) {
412
+ const condition = mapComparison(op, comparison);
413
+ if (condition) return { condition };
414
+ }
415
+ }
416
+
417
+ // Check contains
418
+ if (expr.contains) {
419
+ const condition = mapContains(expr.contains);
420
+ if (condition) return { condition };
421
+ }
422
+
423
+ // Check like
424
+ if (expr.like) {
425
+ const condition = mapLike(expr.like);
426
+ if (condition) return { condition };
427
+ }
428
+
429
+ // Can't map - return as raw JSON
430
+ return { raw: JSON.stringify(body) };
431
+ }
432
+
433
+ function mapComparison(op: string, args: { left: CedarExpr; right: CedarExpr }): PolicyCondition | null {
434
+ const field = extractContextField(args.left);
435
+ if (!field) return null;
436
+
437
+ const value = extractLiteralValue(args.right);
438
+ if (value === undefined) return null;
439
+
440
+ const operator = mapOperator(op);
441
+ if (!operator) return null;
442
+
443
+ return { field, operator, value };
444
+ }
445
+
446
+ function mapContains(args: { left: CedarExpr; right: CedarExpr }): PolicyCondition | null {
447
+ const field = extractContextField(args.left);
448
+ if (!field) return null;
449
+
450
+ const value = extractLiteralValue(args.right);
451
+ if (value === undefined) return null;
452
+
453
+ return { field, operator: "contains", value };
454
+ }
455
+
456
+ function mapLike(args: { left: CedarExpr; pattern: Array<"Wildcard" | { Literal: string }> }): PolicyCondition | null {
457
+ const field = extractContextField(args.left);
458
+ if (!field) return null;
459
+
460
+ // Convert pattern to string (e.g., ["Wildcard", { Literal: "foo" }, "Wildcard"] -> "*foo*")
461
+ // Escape literal * characters to distinguish from wildcards on round-trip
462
+ const patternStr = args.pattern.map(p => {
463
+ if (p === "Wildcard") return "*";
464
+ if (typeof p === "object" && "Literal" in p) return p.Literal.replace(/\*/g, "\\*");
465
+ return "";
466
+ }).join("");
467
+
468
+ return { field, operator: "like", value: patternStr };
469
+ }
470
+
471
+ /**
472
+ * Extract field name from context.field access pattern
473
+ * Pattern: { ".": { left: { Var: "context" }, attr: "field_name" } }
474
+ */
475
+ function extractContextField(expr: CedarExpr): string | null {
476
+ const dotAccess = expr["."];
477
+ if (!dotAccess) return null;
478
+
479
+ // Check if accessing context variable
480
+ const leftExpr = dotAccess.left;
481
+ if (leftExpr.Var !== "context") return null;
482
+
483
+ return dotAccess.attr;
484
+ }
485
+
486
+ /**
487
+ * Extract literal value from Cedar JSON
488
+ * Pattern: { Value: <literal> }
489
+ */
490
+ function extractLiteralValue(expr: CedarExpr): string | number | boolean | string[] | undefined {
491
+ if (!("Value" in expr)) return undefined;
492
+
493
+ const value = expr.Value;
494
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
495
+ return value;
496
+ }
497
+ if (Array.isArray(value) && value.every(v => typeof v === "string")) {
498
+ return value as string[];
499
+ }
500
+
501
+ return undefined;
502
+ }
503
+
504
+ /**
505
+ * Map Cedar operator to ConditionOperator
506
+ */
507
+ function mapOperator(cedarOp: string): ConditionOperator | null {
508
+ const mapping: Record<string, ConditionOperator> = {
509
+ "==": "eq",
510
+ "!=": "neq",
511
+ "<": "lt",
512
+ "<=": "lte",
513
+ ">": "gt",
514
+ ">=": "gte",
515
+ };
516
+ return mapping[cedarOp] || null;
517
+ }