@intentius/chant-lexicon-cedar 0.124.31 → 0.124.33
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/dist/agentcore/embed.js +250 -0
- package/dist/agentcore/enforcement.js +81 -0
- package/dist/agentcore/scan.js +93 -0
- package/dist/avp/ambient.js +100 -0
- package/dist/avp/client.js +318 -0
- package/dist/avp/describe-resources.js +144 -0
- package/dist/avp/embed.js +125 -0
- package/dist/avp/live-export.js +151 -0
- package/dist/avp/ownership.js +144 -0
- package/dist/avp/statement.js +45 -0
- package/dist/avp/store.js +105 -0
- package/dist/avp/testdata/mock-transport.js +100 -0
- package/dist/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.js +133 -0
- package/dist/codegen/emit.js +331 -0
- package/dist/codegen/generate-cli.js +18 -0
- package/dist/codegen/generate.js +122 -0
- package/dist/codegen/naming.js +90 -0
- package/dist/codegen/package.js +33 -0
- package/dist/commands.js +86 -0
- package/dist/composites/deny-by-default-set.js +51 -0
- package/dist/composites/index.js +9 -0
- package/dist/composites/owner-can-manage.js +48 -0
- package/dist/config.js +159 -0
- package/dist/coverage.js +112 -0
- package/dist/detect.js +61 -0
- package/dist/dogwood/cli.js +477 -0
- package/dist/dogwood/event-schema.js +219 -0
- package/dist/dogwood/index.js +32 -0
- package/dist/dogwood/macros.js +175 -0
- package/dist/dogwood/policy.js +58 -0
- package/dist/dogwood/replay-activity.js +313 -0
- package/dist/dogwood/replay-op.js +134 -0
- package/dist/dogwood/scan.js +234 -0
- package/dist/dogwood/serialize.js +199 -0
- package/dist/dogwood/temporal.js +354 -0
- package/dist/dogwood/trace.js +269 -0
- package/dist/dogwood/upstream.js +40 -0
- package/dist/dogwood/window.js +82 -0
- package/dist/gate-policy-pack.js +189 -0
- package/dist/gate-policy.js +168 -0
- package/dist/gate-schema.js +122 -0
- package/dist/generated/index.js +94 -0
- package/dist/generated/runtime.js +2 -0
- package/dist/import/adapter.js +54 -0
- package/dist/import/clause-text.js +166 -0
- package/dist/import/generator.js +178 -0
- package/dist/import/parser.js +273 -0
- package/dist/index.js +79 -0
- package/dist/init-templates.js +330 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +65 -0
- package/dist/lint/audit-lineage.js +23 -0
- package/dist/lint/post-synth/cedar-helpers.js +123 -0
- package/dist/lint/post-synth/cedc010.js +59 -0
- package/dist/lint/post-synth/cedc011.js +29 -0
- package/dist/lint/post-synth/cedc012.js +34 -0
- package/dist/lint/post-synth/cedc013.js +45 -0
- package/dist/lint/post-synth/cedc014.js +54 -0
- package/dist/lint/post-synth/cede010.js +51 -0
- package/dist/lint/post-synth/cede011.js +36 -0
- package/dist/lint/post-synth/ceds010.js +31 -0
- package/dist/lint/post-synth/ceds011.js +22 -0
- package/dist/lint/post-synth/ceds012.js +31 -0
- package/dist/lint/post-synth/dogwood-helpers.js +94 -0
- package/dist/lint/post-synth/dwdc010.js +38 -0
- package/dist/lint/post-synth/dwdc011.js +34 -0
- package/dist/lint/post-synth/dwdc012.js +24 -0
- package/dist/lint/post-synth/dwdc013.js +29 -0
- package/dist/lint/post-synth/dwde010.js +84 -0
- package/dist/lint/post-synth/dwde011.js +67 -0
- package/dist/lint/post-synth/dwds010.js +21 -0
- package/dist/lint/post-synth/index.js +36 -0
- package/dist/lint/post-synth/wasm-helpers.js +234 -0
- package/dist/lint/rules/index.js +9 -0
- package/dist/lint/rules/policy-shape.js +114 -0
- package/dist/lsp/completions.js +52 -0
- package/dist/lsp/hover.js +56 -0
- package/dist/lsp/registry.d.ts.map +1 -1
- package/dist/lsp/registry.js +51 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/index.js +82 -0
- package/dist/mcp/policy-coverage.js +134 -0
- package/dist/op/activities/index.js +16 -0
- package/dist/package-cli.js +18 -0
- package/dist/plugin.js +304 -0
- package/dist/policy-text.js +117 -0
- package/dist/schema-artifact.js +66 -0
- package/dist/serializer.js +203 -0
- package/dist/spec/fetch.js +96 -0
- package/dist/spec/parse.js +164 -0
- package/dist/spec/pin.js +170 -0
- package/dist/spec/wasm.js +194 -0
- package/dist/validate-cli.js +7 -0
- package/dist/validate.js +74 -0
- package/package.json +7 -7
- package/src/codegen/package.ts +2 -2
- package/src/coverage.ts +2 -2
- package/src/import/generator.ts +2 -2
- package/src/lsp/registry.ts +2 -1
- package/src/spec/fetch.ts +2 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between the cedar-shaped parser/generator above and the core
|
|
3
|
+
* `TemplateParser` / `TypeScriptGenerator` that `chant import` drives.
|
|
4
|
+
*
|
|
5
|
+
* Core's pipeline speaks `TemplateIR` — a flat list of `ResourceIR` with a
|
|
6
|
+
* `type` string it may reorganize into category files. A Cedar policy maps
|
|
7
|
+
* cleanly onto one: the logical id is the policy id, and the type records
|
|
8
|
+
* whether Cedar read it as a static policy or as a template, so the mapping
|
|
9
|
+
* reverses without guessing.
|
|
10
|
+
*/
|
|
11
|
+
import { CedarParser } from "./parser.js";
|
|
12
|
+
import { CedarGenerator, loadActionConstants } from "./generator.js";
|
|
13
|
+
/** The `ResourceIR.type` each IR kind is filed under. */
|
|
14
|
+
const TYPE_BY_KIND = {
|
|
15
|
+
policy: "Cedar::Policy",
|
|
16
|
+
template: "Cedar::Template",
|
|
17
|
+
};
|
|
18
|
+
const KIND_BY_TYPE = {
|
|
19
|
+
"Cedar::Policy": "policy",
|
|
20
|
+
"Cedar::Template": "template",
|
|
21
|
+
};
|
|
22
|
+
function toResource(entity) {
|
|
23
|
+
return {
|
|
24
|
+
logicalId: entity.name,
|
|
25
|
+
type: TYPE_BY_KIND[entity.kind],
|
|
26
|
+
properties: entity.props,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function toPolicyIR(resource) {
|
|
30
|
+
return {
|
|
31
|
+
kind: KIND_BY_TYPE[resource.type] ?? "policy",
|
|
32
|
+
name: resource.logicalId,
|
|
33
|
+
props: resource.properties,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** `TemplateParser` over `.cedar` text and the JSON policy-set envelope. */
|
|
37
|
+
export class CedarTemplateParser {
|
|
38
|
+
parse(content) {
|
|
39
|
+
const { entities, warnings } = new CedarParser().parse(content);
|
|
40
|
+
return {
|
|
41
|
+
resources: entities.map(toResource),
|
|
42
|
+
parameters: [],
|
|
43
|
+
...(warnings.length > 0 ? { metadata: { warnings } } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** `TypeScriptGenerator` emitting `new Policy({ … })` per imported policy. */
|
|
48
|
+
export class CedarTemplateGenerator {
|
|
49
|
+
generate(ir) {
|
|
50
|
+
const generator = new CedarGenerator({ actionConstants: loadActionConstants() });
|
|
51
|
+
const { source } = generator.generate(ir.resources.map(toPolicyIR));
|
|
52
|
+
return [{ path: "main.ts", content: source }];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recover the `when`/`unless` bodies of a policy exactly as they were written.
|
|
3
|
+
*
|
|
4
|
+
* The round-trip this lexicon promises is byte-identical `.cedar` output, and
|
|
5
|
+
* the JSON leg cannot deliver it: an expression that goes to `PolicyJson` and
|
|
6
|
+
* back comes out defensively parenthesized — `resource.owner == principal`
|
|
7
|
+
* returns as `(resource.owner) == principal` — and `formatPolicies` does not
|
|
8
|
+
* undo that. The tree is semantically lossless; the text is not preserved.
|
|
9
|
+
*
|
|
10
|
+
* So when there *is* source text, the condition bodies are lifted out of it
|
|
11
|
+
* verbatim, and the module still decides what they mean: every body this file
|
|
12
|
+
* hands back is re-parsed and compared against the tree `policyToJson` produced
|
|
13
|
+
* for the same clause before the importer trusts it (see `parser.ts`). A
|
|
14
|
+
* mismatch falls back to the rendered form.
|
|
15
|
+
*
|
|
16
|
+
* What is here is a *delimiter scanner*, not a Cedar parser. It knows three
|
|
17
|
+
* things — that a `"` starts a string with backslash escapes, that `//` runs
|
|
18
|
+
* to end of line, and that `@`, `(` and `{` have partners — and it knows the
|
|
19
|
+
* two clause keywords. It has no opinion about expressions, which remains the
|
|
20
|
+
* module's job. Anything it does not recognize returns `null`, and the caller
|
|
21
|
+
* uses the rendered bodies instead.
|
|
22
|
+
*/
|
|
23
|
+
const WHITESPACE = /\s/;
|
|
24
|
+
const WORD_CHAR = /[A-Za-z0-9_]/;
|
|
25
|
+
class Scanner {
|
|
26
|
+
constructor(text) {
|
|
27
|
+
this.text = text;
|
|
28
|
+
this.index = 0;
|
|
29
|
+
}
|
|
30
|
+
atEnd() {
|
|
31
|
+
return this.index >= this.text.length;
|
|
32
|
+
}
|
|
33
|
+
peek() {
|
|
34
|
+
return this.text[this.index] ?? "";
|
|
35
|
+
}
|
|
36
|
+
/** Consume whitespace and `//` line comments. */
|
|
37
|
+
skipTrivia() {
|
|
38
|
+
while (this.index < this.text.length) {
|
|
39
|
+
const ch = this.text[this.index];
|
|
40
|
+
if (WHITESPACE.test(ch)) {
|
|
41
|
+
this.index++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ch === "/" && this.text[this.index + 1] === "/") {
|
|
45
|
+
while (this.index < this.text.length && this.text[this.index] !== "\n")
|
|
46
|
+
this.index++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Consume one `[A-Za-z0-9_]+` run, or `null` if the cursor is not on one. */
|
|
53
|
+
word() {
|
|
54
|
+
const start = this.index;
|
|
55
|
+
while (this.index < this.text.length && WORD_CHAR.test(this.text[this.index]))
|
|
56
|
+
this.index++;
|
|
57
|
+
return this.index > start ? this.text.slice(start, this.index) : null;
|
|
58
|
+
}
|
|
59
|
+
/** Consume the single character `ch`, reporting whether it was there. */
|
|
60
|
+
expect(ch) {
|
|
61
|
+
if (this.text[this.index] !== ch)
|
|
62
|
+
return false;
|
|
63
|
+
this.index++;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Consume a balanced `open`…`close` run starting at the cursor and return
|
|
68
|
+
* what was between them.
|
|
69
|
+
*
|
|
70
|
+
* Strings and line comments are skipped wholesale, so a `}` inside
|
|
71
|
+
* `"}; hi {"` or behind a `//` never closes anything.
|
|
72
|
+
*/
|
|
73
|
+
delimited(open, close) {
|
|
74
|
+
if (!this.expect(open))
|
|
75
|
+
return null;
|
|
76
|
+
const start = this.index;
|
|
77
|
+
let depth = 1;
|
|
78
|
+
while (this.index < this.text.length) {
|
|
79
|
+
const ch = this.text[this.index];
|
|
80
|
+
if (ch === '"') {
|
|
81
|
+
if (!this.skipString())
|
|
82
|
+
return null;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ch === "/" && this.text[this.index + 1] === "/") {
|
|
86
|
+
this.skipTrivia();
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (ch === open)
|
|
90
|
+
depth++;
|
|
91
|
+
if (ch === close) {
|
|
92
|
+
depth--;
|
|
93
|
+
if (depth === 0) {
|
|
94
|
+
const body = this.text.slice(start, this.index);
|
|
95
|
+
this.index++;
|
|
96
|
+
return body;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.index++;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/** Consume a double-quoted literal, honouring backslash escapes. */
|
|
104
|
+
skipString() {
|
|
105
|
+
this.index++; // opening quote
|
|
106
|
+
while (this.index < this.text.length) {
|
|
107
|
+
const ch = this.text[this.index];
|
|
108
|
+
if (ch === "\\") {
|
|
109
|
+
this.index += 2;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
this.index++;
|
|
113
|
+
if (ch === '"')
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Split one policy's source into its clause bodies.
|
|
121
|
+
*
|
|
122
|
+
* Returns `null` for anything that does not walk cleanly — an unbalanced
|
|
123
|
+
* brace, a keyword that is neither `when` nor `unless`, trailing text after
|
|
124
|
+
* the terminating `;`. `null` is not a diagnosis, only a decision to use the
|
|
125
|
+
* module's rendering instead.
|
|
126
|
+
*/
|
|
127
|
+
export function extractClauses(policyText) {
|
|
128
|
+
const scanner = new Scanner(policyText);
|
|
129
|
+
// Annotations: `@key( "value" )`, any number of them.
|
|
130
|
+
scanner.skipTrivia();
|
|
131
|
+
while (scanner.peek() === "@") {
|
|
132
|
+
scanner.expect("@");
|
|
133
|
+
if (scanner.word() === null)
|
|
134
|
+
return null;
|
|
135
|
+
scanner.skipTrivia();
|
|
136
|
+
if (scanner.delimited("(", ")") === null)
|
|
137
|
+
return null;
|
|
138
|
+
scanner.skipTrivia();
|
|
139
|
+
}
|
|
140
|
+
const effect = scanner.word();
|
|
141
|
+
if (effect !== "permit" && effect !== "forbid")
|
|
142
|
+
return null;
|
|
143
|
+
// The scope block. Its contents are of no interest here; skipping it is what
|
|
144
|
+
// keeps the `(` of `@id(` from being mistaken for the scope's own.
|
|
145
|
+
scanner.skipTrivia();
|
|
146
|
+
if (scanner.delimited("(", ")") === null)
|
|
147
|
+
return null;
|
|
148
|
+
const clauses = [];
|
|
149
|
+
for (;;) {
|
|
150
|
+
scanner.skipTrivia();
|
|
151
|
+
if (scanner.expect(";"))
|
|
152
|
+
break;
|
|
153
|
+
const keyword = scanner.word();
|
|
154
|
+
if (keyword !== "when" && keyword !== "unless")
|
|
155
|
+
return null;
|
|
156
|
+
scanner.skipTrivia();
|
|
157
|
+
const body = scanner.delimited("{", "}");
|
|
158
|
+
if (body === null)
|
|
159
|
+
return null;
|
|
160
|
+
clauses.push({ kind: keyword, body: body.trim() });
|
|
161
|
+
}
|
|
162
|
+
scanner.skipTrivia();
|
|
163
|
+
if (!scanner.atEnd())
|
|
164
|
+
return null;
|
|
165
|
+
return clauses;
|
|
166
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript generator for `chant import`.
|
|
3
|
+
*
|
|
4
|
+
* Turns the parser's IR back into the authoring source it came from: one
|
|
5
|
+
* `import` line, then an `export const … = new Policy({ … })` per policy.
|
|
6
|
+
*
|
|
7
|
+
* `Policy` is the schema-driven class `chant generate` emits, and the scope
|
|
8
|
+
* refs it accepts are string-literal unions — so `action: { eq: 'App::Action::"read"' }`
|
|
9
|
+
* is already checked against the schema by the compiler, and an action the
|
|
10
|
+
* schema never declared is an error at the import site. Where the generated
|
|
11
|
+
* registry is on hand, an action UID is emitted as the generated *constant*
|
|
12
|
+
* (`ReadAction`) instead, which reads better and survives a rename; without a
|
|
13
|
+
* registry the literal is the fallback, and it typechecks just the same.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync } from "fs";
|
|
16
|
+
import { dirname, join } from "path";
|
|
17
|
+
import { moduleSourceDir } from "@intentius/chant/module-source";
|
|
18
|
+
/** The package this generated source imports from. */
|
|
19
|
+
export const CEDAR_PACKAGE = "@intentius/chant-lexicon-cedar";
|
|
20
|
+
/** The authoring class every imported policy is emitted against. */
|
|
21
|
+
const POLICY_CLASS = "Policy";
|
|
22
|
+
// ── Identifier sanitization ───────────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* A word no export may be called. Not the full reserved list — only the ones a
|
|
25
|
+
* policy id plausibly reduces to.
|
|
26
|
+
*/
|
|
27
|
+
const RESERVED = new Set([
|
|
28
|
+
"break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do",
|
|
29
|
+
"else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import",
|
|
30
|
+
"in", "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try",
|
|
31
|
+
"typeof", "var", "void", "while", "with", "yield", "let", "static", "await", "interface",
|
|
32
|
+
"package", "private", "protected", "public", "implements",
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* A Cedar policy id as a JavaScript identifier.
|
|
36
|
+
*
|
|
37
|
+
* `allow-alice-read` → `allowAliceRead`, `legacy_policy` → `legacyPolicy`,
|
|
38
|
+
* `SOC2-AC-3` → `soc2AC3`. A leading segment that is already mixed case keeps
|
|
39
|
+
* its shape apart from the first letter; one that is all caps is lowered whole,
|
|
40
|
+
* so an id like `SOC2` does not come back as `sOC2`.
|
|
41
|
+
*/
|
|
42
|
+
export function sanitizeName(id) {
|
|
43
|
+
const segments = id.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
44
|
+
if (segments.length === 0)
|
|
45
|
+
return "policy";
|
|
46
|
+
const [first, ...rest] = segments;
|
|
47
|
+
const head = /[a-z]/.test(first) ? first[0].toLowerCase() + first.slice(1) : first.toLowerCase();
|
|
48
|
+
const tail = rest.map((segment) => segment[0].toUpperCase() + segment.slice(1)).join("");
|
|
49
|
+
let name = head + tail;
|
|
50
|
+
if (/^[0-9]/.test(name))
|
|
51
|
+
name = `policy${name[0].toUpperCase()}${name.slice(1)}`;
|
|
52
|
+
if (RESERVED.has(name))
|
|
53
|
+
name = `${name}Policy`;
|
|
54
|
+
return name;
|
|
55
|
+
}
|
|
56
|
+
/** Give every export a distinct name, in a way a second run reproduces. */
|
|
57
|
+
function uniqueNames(entities) {
|
|
58
|
+
const taken = new Set();
|
|
59
|
+
return entities.map((entity) => {
|
|
60
|
+
const base = sanitizeName(entity.name);
|
|
61
|
+
let candidate = base;
|
|
62
|
+
let n = 2;
|
|
63
|
+
while (taken.has(candidate))
|
|
64
|
+
candidate = `${base}${n++}`;
|
|
65
|
+
taken.add(candidate);
|
|
66
|
+
return candidate;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// ── Props emission ────────────────────────────────────────────────
|
|
70
|
+
/**
|
|
71
|
+
* A marker no Cedar value can contain, so a substitution cannot be confused
|
|
72
|
+
* for content. The props go through `JSON.stringify` as data; identifiers are
|
|
73
|
+
* swapped in afterwards by matching the encoded marker exactly.
|
|
74
|
+
*/
|
|
75
|
+
const MARKER = "\u0000chant:";
|
|
76
|
+
const SCOPE_KEYS = ["principal", "action", "resource"];
|
|
77
|
+
/**
|
|
78
|
+
* Swap recognized action UIDs for their generated constant, in scope positions
|
|
79
|
+
* only.
|
|
80
|
+
*
|
|
81
|
+
* Not inside `when`/`unless`: those are Cedar expression text, where the UID is
|
|
82
|
+
* part of a larger expression the compiler never sees as a ref.
|
|
83
|
+
*/
|
|
84
|
+
function markScopeConstants(props, actionConstants, used) {
|
|
85
|
+
if (Object.keys(actionConstants).length === 0)
|
|
86
|
+
return props;
|
|
87
|
+
const mark = (value) => {
|
|
88
|
+
if (typeof value !== "string")
|
|
89
|
+
return value;
|
|
90
|
+
const constant = actionConstants[value];
|
|
91
|
+
if (!constant)
|
|
92
|
+
return value;
|
|
93
|
+
used.add(constant);
|
|
94
|
+
return `${MARKER}${constant}`;
|
|
95
|
+
};
|
|
96
|
+
const out = { ...props };
|
|
97
|
+
for (const key of SCOPE_KEYS) {
|
|
98
|
+
const scope = out[key];
|
|
99
|
+
if (!scope || typeof scope !== "object")
|
|
100
|
+
continue;
|
|
101
|
+
const marked = { ...scope };
|
|
102
|
+
if ("eq" in marked)
|
|
103
|
+
marked.eq = mark(marked.eq);
|
|
104
|
+
if (Array.isArray(marked.in))
|
|
105
|
+
marked.in = marked.in.map(mark);
|
|
106
|
+
else if ("in" in marked)
|
|
107
|
+
marked.in = mark(marked.in);
|
|
108
|
+
out[key] = marked;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/** `{"effect": "permit"}` → `{ effect: "permit" }`, and markers → identifiers. */
|
|
113
|
+
function emitProps(props) {
|
|
114
|
+
const json = JSON.stringify(props, null, 2)
|
|
115
|
+
// Unquote keys that are already valid identifiers. A quote inside a string
|
|
116
|
+
// value is escaped as `\"` by `JSON.stringify`, so no value can present the
|
|
117
|
+
// `"ident":` shape this matches.
|
|
118
|
+
.replace(/"([A-Za-z_$][A-Za-z0-9_$]*)":/g, "$1:");
|
|
119
|
+
return json.replace(/"\\u0000chant:([A-Za-z_$][A-Za-z0-9_$]*)"/g, "$1");
|
|
120
|
+
}
|
|
121
|
+
// ── Generator ─────────────────────────────────────────────────────
|
|
122
|
+
export class CedarGenerator {
|
|
123
|
+
constructor(options = {}) {
|
|
124
|
+
this.actionConstants = options.actionConstants ?? {};
|
|
125
|
+
}
|
|
126
|
+
generate(entities) {
|
|
127
|
+
const warnings = [];
|
|
128
|
+
const imports = new Set([POLICY_CLASS]);
|
|
129
|
+
const names = uniqueNames(entities);
|
|
130
|
+
const blocks = [];
|
|
131
|
+
for (const [index, entity] of entities.entries()) {
|
|
132
|
+
const props = markScopeConstants(entity.props, this.actionConstants, imports);
|
|
133
|
+
if (entity.kind === "template") {
|
|
134
|
+
blocks.push("/** Cedar template — its `?principal`/`?resource` slots are filled at link time. */");
|
|
135
|
+
}
|
|
136
|
+
blocks.push(`export const ${names[index]} = new ${POLICY_CLASS}(${emitProps(props)});`);
|
|
137
|
+
blocks.push("");
|
|
138
|
+
}
|
|
139
|
+
if (entities.length === 0)
|
|
140
|
+
warnings.push("no Cedar policies were found in this document");
|
|
141
|
+
const importLine = `import { ${[...imports].sort().join(", ")} } from "${CEDAR_PACKAGE}";`;
|
|
142
|
+
const source = [importLine, "", ...blocks].join("\n");
|
|
143
|
+
return { source: source.endsWith("\n") ? source : `${source}\n`, warnings };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// ── Generated registry lookup ─────────────────────────────────────
|
|
147
|
+
/** Filename of the registry `chant generate` writes beside the typed classes. */
|
|
148
|
+
const REGISTRY_FILE = "lexicon-cedar.json";
|
|
149
|
+
/**
|
|
150
|
+
* Read `App::Action::"read"` → `ReadAction` out of the generated registry.
|
|
151
|
+
*
|
|
152
|
+
* Best-effort by design: a project that has not run `chant generate` has no
|
|
153
|
+
* registry, and the generator falls back to emitting UID literals — which the
|
|
154
|
+
* `Policy` class accepts and the compiler still checks.
|
|
155
|
+
*/
|
|
156
|
+
export function loadActionConstants(packageDir) {
|
|
157
|
+
const base = packageDir ?? dirname(moduleSourceDir(import.meta.url));
|
|
158
|
+
const path = join(base, "generated", REGISTRY_FILE);
|
|
159
|
+
if (!existsSync(path))
|
|
160
|
+
return {};
|
|
161
|
+
try {
|
|
162
|
+
const registry = JSON.parse(readFileSync(path, "utf-8"));
|
|
163
|
+
const constants = {};
|
|
164
|
+
for (const [tsName, entry] of Object.entries(registry)) {
|
|
165
|
+
if (entry.kind !== "resource")
|
|
166
|
+
continue;
|
|
167
|
+
if (typeof entry.resourceType !== "string")
|
|
168
|
+
continue;
|
|
169
|
+
if (!/::Action::"/.test(entry.resourceType))
|
|
170
|
+
continue;
|
|
171
|
+
constants[entry.resourceType] = tsName;
|
|
172
|
+
}
|
|
173
|
+
return constants;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cedar policy parser for `chant import`.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces come in and one IR goes out:
|
|
5
|
+
*
|
|
6
|
+
* - **`.cedar` policy text** — split by `policySetTextToParts`, then each
|
|
7
|
+
* policy handed to `policyToJson` (or `templateToJson`, for one carrying a
|
|
8
|
+
* slot). The Cedar grammar is never touched here; the module owns it.
|
|
9
|
+
* - **the JSON policy-set envelope** the serializer writes beside the text —
|
|
10
|
+
* `{ staticPolicies, templates, templateLinks }`, whose values are already
|
|
11
|
+
* in Cedar's JSON policy format.
|
|
12
|
+
*
|
|
13
|
+
* The IR is lossless with respect to the authoring model: effect, all three
|
|
14
|
+
* scopes, every `when`/`unless` clause in order, and every annotation. What is
|
|
15
|
+
* *not* in the model is not invented — Cedar comments are dropped by the
|
|
16
|
+
* module before this file sees them, and template links have no authoring
|
|
17
|
+
* shape yet, so they are reported as a warning rather than silently discarded.
|
|
18
|
+
*
|
|
19
|
+
* Unconstrained scopes are the one thing left out of `props`, because `{}` and
|
|
20
|
+
* absent are the same value to the serializer (`principal` with no constraint).
|
|
21
|
+
* Writing `principal: {}` into every imported policy would be noise, not
|
|
22
|
+
* fidelity.
|
|
23
|
+
*/
|
|
24
|
+
import { policyToJson, policyToText, splitPolicySet, templateToJson, } from "../spec/wasm.js";
|
|
25
|
+
import { escapeCedarString } from "../serializer.js";
|
|
26
|
+
import { extractClauses } from "./clause-text.js";
|
|
27
|
+
/** `{ type: "App::User", id: "alice" }` → `App::User::"alice"`. */
|
|
28
|
+
function uidText(uid) {
|
|
29
|
+
const parts = "__entity" in uid ? uid.__entity : uid;
|
|
30
|
+
return `${parts.type}::"${escapeCedarString(parts.id)}"`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One scope constraint, back to the authoring form.
|
|
34
|
+
*
|
|
35
|
+
* `undefined` is the unconstrained case — see the note at the top of the file
|
|
36
|
+
* about why it is omitted rather than written as `{}`.
|
|
37
|
+
*/
|
|
38
|
+
function scopeProps(constraint) {
|
|
39
|
+
if (!constraint || typeof constraint !== "object")
|
|
40
|
+
return undefined;
|
|
41
|
+
const scope = constraint;
|
|
42
|
+
const inTarget = (value) => {
|
|
43
|
+
if (!value || typeof value !== "object")
|
|
44
|
+
return undefined;
|
|
45
|
+
const target = value;
|
|
46
|
+
if (typeof target.slot === "string")
|
|
47
|
+
return target.slot;
|
|
48
|
+
if (Array.isArray(target.entities))
|
|
49
|
+
return target.entities.map((e) => uidText(e));
|
|
50
|
+
if (target.entity)
|
|
51
|
+
return uidText(target.entity);
|
|
52
|
+
return undefined;
|
|
53
|
+
};
|
|
54
|
+
switch (scope.op) {
|
|
55
|
+
case "is": {
|
|
56
|
+
if (typeof scope.entity_type !== "string")
|
|
57
|
+
return undefined;
|
|
58
|
+
const within = inTarget(scope.in);
|
|
59
|
+
return within === undefined ? { is: scope.entity_type } : { is: scope.entity_type, in: within };
|
|
60
|
+
}
|
|
61
|
+
case "==":
|
|
62
|
+
if (typeof scope.slot === "string")
|
|
63
|
+
return { eq: scope.slot };
|
|
64
|
+
return scope.entity ? { eq: uidText(scope.entity) } : undefined;
|
|
65
|
+
case "in": {
|
|
66
|
+
const within = inTarget(scope);
|
|
67
|
+
return within === undefined ? undefined : { in: within };
|
|
68
|
+
}
|
|
69
|
+
default:
|
|
70
|
+
// `All`, and anything a later Cedar version adds that this does not know.
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Render one condition body back to Cedar text through the module.
|
|
76
|
+
*
|
|
77
|
+
* There is no entry point that renders an expression on its own, so the body
|
|
78
|
+
* is wrapped in the most boring policy that can hold it and the wrapper is
|
|
79
|
+
* sliced back off. The wrapper is the module's own output, so its shape is
|
|
80
|
+
* fixed: `permit(principal, action, resource) when { … };`.
|
|
81
|
+
*/
|
|
82
|
+
function renderConditionBody(kind, body) {
|
|
83
|
+
const probe = {
|
|
84
|
+
effect: "permit",
|
|
85
|
+
principal: { op: "All" },
|
|
86
|
+
action: { op: "All" },
|
|
87
|
+
resource: { op: "All" },
|
|
88
|
+
conditions: [{ kind, body }],
|
|
89
|
+
};
|
|
90
|
+
const rendered = policyToText(probe);
|
|
91
|
+
if (!rendered.ok)
|
|
92
|
+
return undefined;
|
|
93
|
+
const opening = `${kind} { `;
|
|
94
|
+
const start = rendered.value.indexOf(opening);
|
|
95
|
+
if (start < 0 || !rendered.value.endsWith(" };"))
|
|
96
|
+
return undefined;
|
|
97
|
+
return rendered.value.slice(start + opening.length, -3);
|
|
98
|
+
}
|
|
99
|
+
/** Re-parse one clause body and return the tree the module makes of it. */
|
|
100
|
+
function parseConditionBody(body) {
|
|
101
|
+
const parsed = policyToJson(`permit (principal, action, resource) when { ${body} };`);
|
|
102
|
+
return parsed.ok ? parsed.value.conditions[0]?.body : undefined;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The `when` and `unless` clause texts, in order.
|
|
106
|
+
*
|
|
107
|
+
* Verbatim source wins when there is source to quote and the module agrees it
|
|
108
|
+
* means the same thing; otherwise the rendered form is used. `policyToJson`
|
|
109
|
+
* serializes deterministically with sorted keys (#1648 §5.2), so comparing two
|
|
110
|
+
* of its trees by their JSON text is sound.
|
|
111
|
+
*/
|
|
112
|
+
function conditionProps(json, sourceText) {
|
|
113
|
+
const conditions = json.conditions ?? [];
|
|
114
|
+
const warnings = [];
|
|
115
|
+
const source = sourceText ? extractClauses(sourceText) : null;
|
|
116
|
+
const usable = source !== null && source.length === conditions.length ? source : null;
|
|
117
|
+
const when = [];
|
|
118
|
+
const unless = [];
|
|
119
|
+
for (const [index, condition] of conditions.entries()) {
|
|
120
|
+
const candidate = usable?.[index];
|
|
121
|
+
let text;
|
|
122
|
+
if (candidate &&
|
|
123
|
+
candidate.kind === condition.kind &&
|
|
124
|
+
JSON.stringify(parseConditionBody(candidate.body)) === JSON.stringify(condition.body)) {
|
|
125
|
+
text = candidate.body;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
text = renderConditionBody(condition.kind, condition.body);
|
|
129
|
+
}
|
|
130
|
+
if (text === undefined) {
|
|
131
|
+
warnings.push(`a ${condition.kind} clause could not be rendered back to Cedar text and was dropped`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
(condition.kind === "when" ? when : unless).push(text);
|
|
135
|
+
}
|
|
136
|
+
return { when, unless, warnings };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* One `PolicyJson` as `Cedar::Policy` props.
|
|
140
|
+
*
|
|
141
|
+
* Key order matches the props interface, so the generator's `JSON.stringify`
|
|
142
|
+
* emits policies that read the way they would have been written.
|
|
143
|
+
*/
|
|
144
|
+
function policyProps(json, sourceText) {
|
|
145
|
+
const props = { effect: json.effect === "forbid" ? "forbid" : "permit" };
|
|
146
|
+
const principal = scopeProps(json.principal);
|
|
147
|
+
if (principal)
|
|
148
|
+
props.principal = principal;
|
|
149
|
+
const action = scopeProps(json.action);
|
|
150
|
+
if (action)
|
|
151
|
+
props.action = action;
|
|
152
|
+
const resource = scopeProps(json.resource);
|
|
153
|
+
if (resource)
|
|
154
|
+
props.resource = resource;
|
|
155
|
+
const { when, unless, warnings } = conditionProps(json, sourceText);
|
|
156
|
+
if (when.length > 0)
|
|
157
|
+
props.when = when;
|
|
158
|
+
if (unless.length > 0)
|
|
159
|
+
props.unless = unless;
|
|
160
|
+
const annotations = json.annotations ?? {};
|
|
161
|
+
if (Object.keys(annotations).length > 0)
|
|
162
|
+
props.annotations = { ...annotations };
|
|
163
|
+
return { props, warnings };
|
|
164
|
+
}
|
|
165
|
+
/** The id a policy answers to: its own `@id` first, then where it was filed. */
|
|
166
|
+
function policyId(json, fallback) {
|
|
167
|
+
const annotated = json.annotations?.id;
|
|
168
|
+
return typeof annotated === "string" && annotated.length > 0 ? annotated : fallback;
|
|
169
|
+
}
|
|
170
|
+
// ── Envelope shape ────────────────────────────────────────────────
|
|
171
|
+
const ENVELOPE_KEYS = new Set(["staticPolicies", "templates", "templateLinks"]);
|
|
172
|
+
/**
|
|
173
|
+
* Is this the JSON policy-set envelope?
|
|
174
|
+
*
|
|
175
|
+
* Deliberately strict about foreign keys: a CloudFormation template or a
|
|
176
|
+
* Kubernetes manifest must not walk in through a permissive shape test, and
|
|
177
|
+
* `detect.ts` leans on the same rule.
|
|
178
|
+
*/
|
|
179
|
+
export function isPolicySetEnvelope(data) {
|
|
180
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
181
|
+
return false;
|
|
182
|
+
const keys = Object.keys(data);
|
|
183
|
+
if (keys.length === 0)
|
|
184
|
+
return false;
|
|
185
|
+
if (!keys.every((key) => ENVELOPE_KEYS.has(key)))
|
|
186
|
+
return false;
|
|
187
|
+
return "staticPolicies" in data || "templates" in data;
|
|
188
|
+
}
|
|
189
|
+
/** Normalize the three shapes `staticPolicies` is allowed to take into pairs. */
|
|
190
|
+
function envelopeEntries(value, fallbackPrefix) {
|
|
191
|
+
if (Array.isArray(value))
|
|
192
|
+
return value.map((entry, index) => [`${fallbackPrefix}${index}`, entry]);
|
|
193
|
+
if (value && typeof value === "object")
|
|
194
|
+
return Object.entries(value);
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
// ── Parser ────────────────────────────────────────────────────────
|
|
198
|
+
export class CedarParser {
|
|
199
|
+
parse(content) {
|
|
200
|
+
if (!content.trim())
|
|
201
|
+
return { entities: [], warnings: [] };
|
|
202
|
+
const asJSON = tryParseJSON(content);
|
|
203
|
+
if (isPolicySetEnvelope(asJSON))
|
|
204
|
+
return this.parseEnvelope(asJSON);
|
|
205
|
+
return this.parseText(content);
|
|
206
|
+
}
|
|
207
|
+
// ── `.cedar` text ───────────────────────────────────────────────
|
|
208
|
+
parseText(content) {
|
|
209
|
+
const parts = splitPolicySet(content);
|
|
210
|
+
if (!parts.ok)
|
|
211
|
+
throw new Error(`cedar: could not parse the policy document — ${parts.error}`);
|
|
212
|
+
const entities = [];
|
|
213
|
+
const warnings = [];
|
|
214
|
+
const collect = (sources, kind, convert, fallbackPrefix) => {
|
|
215
|
+
for (const [index, source] of sources.entries()) {
|
|
216
|
+
const converted = convert(source);
|
|
217
|
+
if (!converted.ok) {
|
|
218
|
+
warnings.push(`skipped a ${kind} that did not convert: ${converted.error}`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
entities.push(this.toIR(kind, converted.value, `${fallbackPrefix}${index}`, source, warnings));
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
collect(parts.value.policies, "policy", policyToJson, "policy");
|
|
225
|
+
collect(parts.value.templates, "template", templateToJson, "template");
|
|
226
|
+
return { entities, warnings };
|
|
227
|
+
}
|
|
228
|
+
// ── JSON policy-set envelope ────────────────────────────────────
|
|
229
|
+
parseEnvelope(envelope) {
|
|
230
|
+
// `staticPolicies` is allowed to be one blob of `.cedar` text, in which
|
|
231
|
+
// case the whole envelope is the text surface wearing a wrapper — templates
|
|
232
|
+
// and all, since the text is what says which is which.
|
|
233
|
+
const asText = typeof envelope.staticPolicies === "string" ? this.parseText(envelope.staticPolicies) : null;
|
|
234
|
+
const entities = [...(asText?.entities ?? [])];
|
|
235
|
+
const warnings = [...(asText?.warnings ?? [])];
|
|
236
|
+
const collect = (value, kind, convert, fallbackPrefix) => {
|
|
237
|
+
for (const [key, entry] of envelopeEntries(value, fallbackPrefix)) {
|
|
238
|
+
const converted = convert(entry);
|
|
239
|
+
if (!converted.ok) {
|
|
240
|
+
warnings.push(`skipped ${kind} \`${key}\`: ${converted.error}`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// A policy given as text carries its own layout; one given as JSON has
|
|
244
|
+
// none to quote, and its conditions come back as the module renders them.
|
|
245
|
+
const source = typeof entry === "string" ? entry : undefined;
|
|
246
|
+
entities.push(this.toIR(kind, converted.value, key, source, warnings));
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
if (!asText)
|
|
250
|
+
collect(envelope.staticPolicies, "policy", policyToJson, "policy");
|
|
251
|
+
collect(envelope.templates, "template", templateToJson, "template");
|
|
252
|
+
const links = envelope.templateLinks;
|
|
253
|
+
if (Array.isArray(links) && links.length > 0) {
|
|
254
|
+
warnings.push(`${links.length} template link(s) were not imported — a linked template has no authoring shape in this lexicon yet`);
|
|
255
|
+
}
|
|
256
|
+
return { entities, warnings };
|
|
257
|
+
}
|
|
258
|
+
toIR(kind, json, fallbackId, sourceText, warnings) {
|
|
259
|
+
const name = policyId(json, fallbackId);
|
|
260
|
+
const { props, warnings: propWarnings } = policyProps(json, sourceText);
|
|
261
|
+
for (const warning of propWarnings)
|
|
262
|
+
warnings.push(`${name}: ${warning}`);
|
|
263
|
+
return { kind, name, props };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function tryParseJSON(content) {
|
|
267
|
+
try {
|
|
268
|
+
return JSON.parse(content);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|