@intentius/chant-lexicon-cedar 0.44.8 → 0.44.10
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 +106 -2
- package/dist/agentcore/embed.d.ts +189 -0
- package/dist/agentcore/embed.d.ts.map +1 -0
- package/dist/agentcore/enforcement.d.ts +76 -0
- package/dist/agentcore/enforcement.d.ts.map +1 -0
- package/dist/agentcore/scan.d.ts +46 -0
- package/dist/agentcore/scan.d.ts.map +1 -0
- package/dist/codegen/docs-dogwood.d.ts +21 -0
- package/dist/codegen/docs-dogwood.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/package.d.ts.map +1 -1
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/dogwood/cli.d.ts +247 -0
- package/dist/dogwood/cli.d.ts.map +1 -0
- package/dist/dogwood/event-schema.d.ts +161 -0
- package/dist/dogwood/event-schema.d.ts.map +1 -0
- package/dist/dogwood/index.d.ts +39 -0
- package/dist/dogwood/index.d.ts.map +1 -0
- package/dist/dogwood/macros.d.ts +96 -0
- package/dist/dogwood/macros.d.ts.map +1 -0
- package/dist/dogwood/policy.d.ts +120 -0
- package/dist/dogwood/policy.d.ts.map +1 -0
- package/dist/dogwood/replay-activity.d.ts +196 -0
- package/dist/dogwood/replay-activity.d.ts.map +1 -0
- package/dist/dogwood/replay-op.d.ts +165 -0
- package/dist/dogwood/replay-op.d.ts.map +1 -0
- package/dist/dogwood/scan.d.ts +109 -0
- package/dist/dogwood/scan.d.ts.map +1 -0
- package/dist/dogwood/serialize.d.ts +66 -0
- package/dist/dogwood/serialize.d.ts.map +1 -0
- package/dist/dogwood/temporal.d.ts +259 -0
- package/dist/dogwood/temporal.d.ts.map +1 -0
- package/dist/dogwood/trace.d.ts +215 -0
- package/dist/dogwood/trace.d.ts.map +1 -0
- package/dist/dogwood/upstream.d.ts +41 -0
- package/dist/dogwood/upstream.d.ts.map +1 -0
- package/dist/dogwood/window.d.ts +73 -0
- package/dist/dogwood/window.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +15 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/dogwood-helpers.d.ts +63 -0
- package/dist/lint/post-synth/dogwood-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc010.d.ts +25 -0
- package/dist/lint/post-synth/dwdc010.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc011.d.ts +19 -0
- package/dist/lint/post-synth/dwdc011.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc012.d.ts +21 -0
- package/dist/lint/post-synth/dwdc012.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc013.d.ts +33 -0
- package/dist/lint/post-synth/dwdc013.d.ts.map +1 -0
- package/dist/lint/post-synth/dwde010.d.ts +32 -0
- package/dist/lint/post-synth/dwde010.d.ts.map +1 -0
- package/dist/lint/post-synth/dwde011.d.ts +33 -0
- package/dist/lint/post-synth/dwde011.d.ts.map +1 -0
- package/dist/lint/post-synth/dwds010.d.ts +24 -0
- package/dist/lint/post-synth/dwds010.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +7 -0
- package/dist/okf/rules/DWDC010.md +11 -0
- package/dist/okf/rules/DWDC011.md +11 -0
- package/dist/okf/rules/DWDC012.md +11 -0
- package/dist/okf/rules/DWDC013.md +15 -0
- package/dist/okf/rules/DWDE010.md +11 -0
- package/dist/okf/rules/DWDE011.md +11 -0
- package/dist/okf/rules/DWDS010.md +11 -0
- package/dist/okf/types/Policy.md +1 -0
- package/dist/op/activities/index.d.ts +18 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/policy-text.d.ts +53 -0
- package/dist/policy-text.d.ts.map +1 -0
- package/dist/rules/dogwood-helpers.ts +139 -0
- package/dist/rules/dwdc010.ts +62 -0
- package/dist/rules/dwdc011.ts +61 -0
- package/dist/rules/dwdc012.ts +46 -0
- package/dist/rules/dwdc013.ts +64 -0
- package/dist/rules/dwde010.ts +130 -0
- package/dist/rules/dwde011.ts +108 -0
- package/dist/rules/dwds010.ts +46 -0
- package/dist/serializer.d.ts +10 -18
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-cedar-authoring.md +180 -0
- package/dist/skills/chant-cedar-avp-embedding.md +125 -0
- package/dist/skills/chant-cedar-dogwood.md +327 -0
- package/dist/skills/chant-cedar-meta-policy.md +119 -0
- package/package.json +7 -2
- package/src/agentcore/embed.test.ts +254 -0
- package/src/agentcore/embed.ts +399 -0
- package/src/agentcore/enforcement.test.ts +43 -0
- package/src/agentcore/enforcement.ts +92 -0
- package/src/agentcore/scan.ts +119 -0
- package/src/codegen/docs-dogwood.ts +1119 -0
- package/src/codegen/docs.ts +66 -1
- package/src/codegen/package.ts +3 -2
- package/src/config.test.ts +12 -0
- package/src/config.ts +28 -0
- package/src/dogwood/cli.test.ts +513 -0
- package/src/dogwood/cli.ts +666 -0
- package/src/dogwood/event-schema.test.ts +218 -0
- package/src/dogwood/event-schema.ts +318 -0
- package/src/dogwood/index.ts +271 -0
- package/src/dogwood/macros.test.ts +104 -0
- package/src/dogwood/macros.ts +229 -0
- package/src/dogwood/policy.test.ts +94 -0
- package/src/dogwood/policy.ts +141 -0
- package/src/dogwood/replay-activity.test.ts +481 -0
- package/src/dogwood/replay-activity.ts +506 -0
- package/src/dogwood/replay-op.ts +242 -0
- package/src/dogwood/scan.ts +287 -0
- package/src/dogwood/serialize.test.ts +331 -0
- package/src/dogwood/serialize.ts +246 -0
- package/src/dogwood/temporal.test.ts +272 -0
- package/src/dogwood/temporal.ts +592 -0
- package/src/dogwood/testdata/custom-kinds.dwschema +17 -0
- package/src/dogwood/testdata/default-macros.dw +23 -0
- package/src/dogwood/testdata/lowered-read-after-login.json +13 -0
- package/src/dogwood/testdata/max-window-raised.dwschema +25 -0
- package/src/dogwood/testdata/pinned.dwschema +31 -0
- package/src/dogwood/testdata/read-after-login.cedarschema +20 -0
- package/src/dogwood/testdata/read-after-login.dw +17 -0
- package/src/dogwood/testdata/temporal-policies.dw +53 -0
- package/src/dogwood/trace.test.ts +231 -0
- package/src/dogwood/trace.ts +471 -0
- package/src/dogwood/upstream.ts +41 -0
- package/src/dogwood/window.ts +124 -0
- package/src/index.ts +76 -0
- package/src/lint/audit-catalog.ts +64 -0
- package/src/lint/post-synth/dogwood-helpers.ts +139 -0
- package/src/lint/post-synth/dwd-post-synth.test.ts +374 -0
- package/src/lint/post-synth/dwdc010.ts +62 -0
- package/src/lint/post-synth/dwdc011.ts +61 -0
- package/src/lint/post-synth/dwdc012.ts +46 -0
- package/src/lint/post-synth/dwdc013.ts +64 -0
- package/src/lint/post-synth/dwde-post-synth.test.ts +368 -0
- package/src/lint/post-synth/dwde010.ts +130 -0
- package/src/lint/post-synth/dwde011.ts +108 -0
- package/src/lint/post-synth/dwds010.ts +46 -0
- package/src/lint/post-synth/index.ts +14 -0
- package/src/lint/post-synth/post-synth.test.ts +7 -3
- package/src/op/activities/index.ts +27 -0
- package/src/plugin.test.ts +3 -2
- package/src/plugin.ts +30 -0
- package/src/policy-text.ts +128 -0
- package/src/serializer.ts +71 -109
- package/src/skills/chant-cedar-dogwood.md +327 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-schema round trip: typed authoring in, `.dwschema` text out, asserted
|
|
3
|
+
* against fixtures whose shapes are upstream's.
|
|
4
|
+
*
|
|
5
|
+
* The fixtures under `./testdata/` are adapted from
|
|
6
|
+
* `dogwood-policy/dogwood`'s shipped examples and its bundled default schema
|
|
7
|
+
* (Apache-2.0; each file carries its provenance in a header comment). They are
|
|
8
|
+
* not byte copies — upstream aligns field types in columns and chant does not
|
|
9
|
+
* — so what is pinned here is the surface: the directive, the declarations,
|
|
10
|
+
* the spreads, the selectors, and where the pins land.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect } from "vitest";
|
|
13
|
+
import { readFileSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { fileURLToPath } from "url";
|
|
16
|
+
import {
|
|
17
|
+
concrete,
|
|
18
|
+
declaredEventKinds,
|
|
19
|
+
defaultEventSchema,
|
|
20
|
+
eventDeclaration,
|
|
21
|
+
eventSchema,
|
|
22
|
+
field,
|
|
23
|
+
pinContext,
|
|
24
|
+
pinPrincipal,
|
|
25
|
+
pinnedField,
|
|
26
|
+
principalType,
|
|
27
|
+
record,
|
|
28
|
+
renderEventSchema,
|
|
29
|
+
resourceType,
|
|
30
|
+
spreadInputs,
|
|
31
|
+
spreadOutputs,
|
|
32
|
+
} from "./event-schema";
|
|
33
|
+
import { readEventSchema } from "./scan";
|
|
34
|
+
|
|
35
|
+
const TESTDATA = join(fileURLToPath(new URL(".", import.meta.url)), "testdata");
|
|
36
|
+
const UPSTREAM = "dogwood-policy/dogwood@5063bcc2d6d6cf5024d1b0498e6cc8ef52cbcf0c";
|
|
37
|
+
|
|
38
|
+
function fixture(name: string): string {
|
|
39
|
+
return readFileSync(join(TESTDATA, name), "utf-8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The provenance header each fixture carries above the emitted bytes. */
|
|
43
|
+
function attribution(...sources: string[]): string {
|
|
44
|
+
return [
|
|
45
|
+
`// Adapted from ${UPSTREAM} (Apache-2.0):`,
|
|
46
|
+
...sources.map((s) => `// ${s}`),
|
|
47
|
+
"// Upstream's shape; the bytes are what chant's typed builders emit.",
|
|
48
|
+
"",
|
|
49
|
+
"",
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── The default shape ──────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("the default (pinned) event schema", () => {
|
|
56
|
+
test("round-trips to upstream's pinned.dwschema shape", () => {
|
|
57
|
+
expect(fixture("pinned.dwschema")).toBe(
|
|
58
|
+
attribution("dogwood-language/configuration/event-schemas/pinned.dwschema") +
|
|
59
|
+
renderEventSchema(defaultEventSchema()),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("every kind pins callerPrincipal to the deciding request's principal", () => {
|
|
64
|
+
const text = renderEventSchema(defaultEventSchema());
|
|
65
|
+
expect(text.match(/pin callerPrincipal: principalType\(A\) = principal,/g)).toHaveLength(3);
|
|
66
|
+
expect(readEventSchema(text).hasPin).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("request is the deciding kind; response and error are history", () => {
|
|
70
|
+
const text = renderEventSchema(defaultEventSchema());
|
|
71
|
+
expect(text).toContain("decision event <A>::request {");
|
|
72
|
+
expect(text).toContain("\nevent <A>::response {");
|
|
73
|
+
expect(text).toContain("\nevent <A>::error {");
|
|
74
|
+
expect(declaredEventKinds(defaultEventSchema())).toEqual(["request", "response", "error"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("dropping the pin is a named argument, and says so in the file", () => {
|
|
78
|
+
const text = renderEventSchema(defaultEventSchema({ pinCallerPrincipal: false }));
|
|
79
|
+
expect(text).not.toContain("pin callerPrincipal");
|
|
80
|
+
expect(text).toContain("// Cross-principal: callerPrincipal is NOT pinned");
|
|
81
|
+
expect(readEventSchema(text).hasPin).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── max_window ─────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("the max_window directive", () => {
|
|
88
|
+
const raised = () =>
|
|
89
|
+
eventSchema(
|
|
90
|
+
[
|
|
91
|
+
eventDeclaration(
|
|
92
|
+
"request",
|
|
93
|
+
[
|
|
94
|
+
spreadInputs(),
|
|
95
|
+
field("callerPrincipal", principalType()),
|
|
96
|
+
field("callerResource", resourceType()),
|
|
97
|
+
field("requestId", concrete("String")),
|
|
98
|
+
],
|
|
99
|
+
{ decision: true },
|
|
100
|
+
),
|
|
101
|
+
eventDeclaration("response", [
|
|
102
|
+
spreadInputs(),
|
|
103
|
+
spreadOutputs(),
|
|
104
|
+
field("callerPrincipal", principalType()),
|
|
105
|
+
field("callerResource", resourceType()),
|
|
106
|
+
field("requestId", concrete("String")),
|
|
107
|
+
]),
|
|
108
|
+
],
|
|
109
|
+
{
|
|
110
|
+
maxWindow: "30d",
|
|
111
|
+
comment:
|
|
112
|
+
"Raises the look-back cap from the 24h default to 30 days. The directive\nmust come first, before any event declaration.\n\nNo pinned field: this schema correlates across principals on purpose.",
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
test("round-trips to upstream's max_window_raised shape", () => {
|
|
117
|
+
expect(fixture("max-window-raised.dwschema")).toBe(
|
|
118
|
+
attribution("dogwood-docs/examples/max_window_raised/event.dwschema") + renderEventSchema(raised()),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("the directive precedes every declaration, as the grammar requires", () => {
|
|
123
|
+
const lines = renderEventSchema(raised()).split("\n");
|
|
124
|
+
const directive = lines.findIndex((l) => l.startsWith("max_window"));
|
|
125
|
+
const firstEvent = lines.findIndex((l) => l.includes("event <A>::"));
|
|
126
|
+
expect(directive).toBeGreaterThan(-1);
|
|
127
|
+
expect(directive).toBeLessThan(firstEvent);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the scanner reads the cap back out", () => {
|
|
131
|
+
const facts = readEventSchema(renderEventSchema(raised()));
|
|
132
|
+
expect(facts.maxWindowText).toBe("30d");
|
|
133
|
+
expect(facts.maxWindowSeconds).toBe(30 * 86400);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("an absent directive reads back as upstream's 24h default", () => {
|
|
137
|
+
const facts = readEventSchema(renderEventSchema(defaultEventSchema()));
|
|
138
|
+
expect(facts.maxWindowText).toBeUndefined();
|
|
139
|
+
expect(facts.maxWindowSeconds).toBe(24 * 3600);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── Author-defined kinds ───────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
describe("author-defined event kinds", () => {
|
|
146
|
+
const custom = () =>
|
|
147
|
+
eventSchema(
|
|
148
|
+
[
|
|
149
|
+
eventDeclaration("attempt", [spreadInputs(), field("actor", principalType())], { decision: true }),
|
|
150
|
+
eventDeclaration("outcome", [spreadInputs(), spreadOutputs(), field("actor", principalType())]),
|
|
151
|
+
],
|
|
152
|
+
{
|
|
153
|
+
comment:
|
|
154
|
+
"Author-defined event kinds — `attempt` decides, `outcome` is history — and\na renamed injected principal field.",
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
test("round-trips to upstream's login_attempt_custom_kind shape", () => {
|
|
159
|
+
expect(fixture("custom-kinds.dwschema")).toBe(
|
|
160
|
+
attribution("dogwood-docs/examples/login_attempt_custom_kind/event.dwschema") + renderEventSchema(custom()),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("request/response are conventional, not required", () => {
|
|
165
|
+
expect(declaredEventKinds(custom())).toEqual(["attempt", "outcome"]);
|
|
166
|
+
expect(readEventSchema(renderEventSchema(custom())).kinds).toEqual(["attempt", "outcome"]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── Field forms ────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe("field forms", () => {
|
|
173
|
+
test("a record type nests, and its members are addressed as name.member", () => {
|
|
174
|
+
const text = renderEventSchema(
|
|
175
|
+
eventSchema([
|
|
176
|
+
eventDeclaration("request", [
|
|
177
|
+
spreadInputs(),
|
|
178
|
+
field("session", record([field("id", concrete("String")), field("tenant", concrete("String"))])),
|
|
179
|
+
]),
|
|
180
|
+
]),
|
|
181
|
+
);
|
|
182
|
+
expect(text).toContain(" session: {\n id: String,\n tenant: String,\n },");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a context pin renders its dotted path", () => {
|
|
186
|
+
const text = renderEventSchema(
|
|
187
|
+
eventSchema([
|
|
188
|
+
eventDeclaration("request", [pinnedField("tenant", concrete("String"), pinContext("input.tenant"))]),
|
|
189
|
+
]),
|
|
190
|
+
);
|
|
191
|
+
expect(text).toContain(" pin tenant: String = context.input.tenant,");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a scope pin may carry an attribute tail", () => {
|
|
195
|
+
const text = renderEventSchema(
|
|
196
|
+
eventSchema([
|
|
197
|
+
eventDeclaration("request", [pinnedField("dept", concrete("String"), pinPrincipal("dept"))]),
|
|
198
|
+
]),
|
|
199
|
+
);
|
|
200
|
+
expect(text).toContain(" pin dept: String = principal.dept,");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("pinning a whole record is refused — upstream requires a leaf", () => {
|
|
204
|
+
expect(() => pinnedField("group", record([field("a", concrete("String"))]), pinPrincipal())).toThrow(
|
|
205
|
+
/must be a leaf/,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a duplicated event kind is refused at construction", () => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
eventSchema([eventDeclaration("request", []), eventDeclaration("request", [])]),
|
|
212
|
+
).toThrow(/declared twice/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a schema with no declarations is refused", () => {
|
|
216
|
+
expect(() => eventSchema([])).toThrow(/at least one event kind/);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed authoring for the `.dwschema` event-schema DSL.
|
|
3
|
+
*
|
|
4
|
+
* The `.dwschema`/`.cedarschema` question the epic flagged was a false alarm
|
|
5
|
+
* (#1657 §2): they are the two halves of one schema. The required half is the
|
|
6
|
+
* Cedar action schema (`--policy-schema`); this file is the optional half, the
|
|
7
|
+
* service side, which says what event *kinds* exist and what fields each one
|
|
8
|
+
* carries. Upstream's grammar is 136 lines of pest and is purely syntactic —
|
|
9
|
+
* it never consults an action schema, which is why every declaration is
|
|
10
|
+
* written against a symbolic action binder `<A>`:
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* decision event <A>::request {
|
|
14
|
+
* ...inputs(A),
|
|
15
|
+
* pin callerPrincipal: principalType(A) = principal,
|
|
16
|
+
* callerResource: resourceType(A),
|
|
17
|
+
* requestId: String,
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* That symbolic binder is why DWDC010 can check an event *kind* against the
|
|
22
|
+
* emitted schema but not an action: the schema names no actions at all.
|
|
23
|
+
*
|
|
24
|
+
* ## The pin, and why opting out is explicit here
|
|
25
|
+
*
|
|
26
|
+
* `ServiceSchema::defaults()` — what runs when no `--event-schema` is passed —
|
|
27
|
+
* pins `callerPrincipal = principal` on every kind, so every temporal
|
|
28
|
+
* predicate is correlated to the current request's principal and events from
|
|
29
|
+
* other principals are invisible. Supplying *any* event schema opts out of the
|
|
30
|
+
* default wholesale, so a schema emitted without that pin silently widens
|
|
31
|
+
* every temporal predicate in the policy set to cross-principal.
|
|
32
|
+
*
|
|
33
|
+
* {@link defaultEventSchema} therefore carries the pin, and dropping it takes
|
|
34
|
+
* a named argument (`pinCallerPrincipal: false`) that also stamps a comment
|
|
35
|
+
* into the emitted file. DWDS010 reports the resulting schema either way, so
|
|
36
|
+
* the choice is visible in the build and not only in the source.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { renderWindow, window, type TemporalWindow, type WindowLike } from "./window";
|
|
40
|
+
|
|
41
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
42
|
+
const TYPE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
43
|
+
|
|
44
|
+
function assertIdent(value: string, what: string): string {
|
|
45
|
+
if (!IDENT.test(value)) throw new Error(`dogwood: ${what} must be an identifier — got "${value}"`);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Field types ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** The four selectors that read from the bound action. */
|
|
52
|
+
export type EventSelector = "inputs" | "outputs" | "principalType" | "resourceType";
|
|
53
|
+
|
|
54
|
+
/** `principalType(A)`, a nested `{ … }` record, or a concrete Cedar type. */
|
|
55
|
+
export type EventFieldType =
|
|
56
|
+
| { readonly type: "selector"; readonly selector: EventSelector }
|
|
57
|
+
| { readonly type: "record"; readonly fields: readonly EventField[] }
|
|
58
|
+
| { readonly type: "concrete"; readonly name: string };
|
|
59
|
+
|
|
60
|
+
/** `principalType(A)` — the action's principal entity type. */
|
|
61
|
+
export function principalType(): EventFieldType {
|
|
62
|
+
return { type: "selector", selector: "principalType" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `resourceType(A)` — the action's resource entity type. */
|
|
66
|
+
export function resourceType(): EventFieldType {
|
|
67
|
+
return { type: "selector", selector: "resourceType" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `String`, `Long`, `MyApp::OAuthUser` — a concrete (possibly qualified) type. */
|
|
71
|
+
export function concrete(name: string): EventFieldType {
|
|
72
|
+
if (!TYPE_NAME.test(name)) throw new Error(`dogwood: a field type must be a Cedar type name — got "${name}"`);
|
|
73
|
+
return { type: "concrete", name };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `{ … }` — the named field becomes a group addressed as `name.member`. */
|
|
77
|
+
export function record(fields: EventField[]): EventFieldType {
|
|
78
|
+
return { type: "record", fields };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Pins ──────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The right-hand side of a pin: the request-side value the field is forced to
|
|
85
|
+
* match. `principal`/`resource` (with an optional attribute tail) or a
|
|
86
|
+
* `context.<path>`.
|
|
87
|
+
*/
|
|
88
|
+
export type PinTarget =
|
|
89
|
+
| { readonly ref: "principal" | "resource"; readonly attrs: readonly string[] }
|
|
90
|
+
| { readonly ref: "context"; readonly path: readonly string[] };
|
|
91
|
+
|
|
92
|
+
/** `= principal`, or `= principal.dept`. */
|
|
93
|
+
export function pinPrincipal(...attrs: string[]): PinTarget {
|
|
94
|
+
for (const a of attrs) assertIdent(a, "a pin attribute");
|
|
95
|
+
return { ref: "principal", attrs };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `= resource`, or `= resource.owner`. */
|
|
99
|
+
export function pinResource(...attrs: string[]): PinTarget {
|
|
100
|
+
for (const a of attrs) assertIdent(a, "a pin attribute");
|
|
101
|
+
return { ref: "resource", attrs };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `= context.input.user`. At least one path segment; `context` alone is not a pin. */
|
|
105
|
+
export function pinContext(path: string): PinTarget {
|
|
106
|
+
const segments = path.split(".");
|
|
107
|
+
if (segments.length === 0 || segments[0] === "") {
|
|
108
|
+
throw new Error("dogwood: a context pin needs at least one path segment (context.<field>)");
|
|
109
|
+
}
|
|
110
|
+
for (const s of segments) assertIdent(s, "a context pin segment");
|
|
111
|
+
return { ref: "context", path: segments };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Fields ────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** `...inputs(A)` / `...outputs(A)` — splice every field the selector yields. */
|
|
117
|
+
export interface SpreadField {
|
|
118
|
+
readonly field: "spread";
|
|
119
|
+
readonly selector: Extract<EventSelector, "inputs" | "outputs">;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `[pin] name: <type> [= <pin target>]`. */
|
|
123
|
+
export interface NamedField {
|
|
124
|
+
readonly field: "named";
|
|
125
|
+
readonly name: string;
|
|
126
|
+
readonly type: EventFieldType;
|
|
127
|
+
readonly pin?: PinTarget;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One entry in an event declaration's field list. */
|
|
131
|
+
export type EventField = SpreadField | NamedField;
|
|
132
|
+
|
|
133
|
+
/** `...inputs(A)`. */
|
|
134
|
+
export function spreadInputs(): SpreadField {
|
|
135
|
+
return { field: "spread", selector: "inputs" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `...outputs(A)`. */
|
|
139
|
+
export function spreadOutputs(): SpreadField {
|
|
140
|
+
return { field: "spread", selector: "outputs" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `name: <type>` — an injected field with an explicit type. */
|
|
144
|
+
export function field(name: string, type: EventFieldType): NamedField {
|
|
145
|
+
return { field: "named", name: assertIdent(name, "a field name"), type };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `pin name: <type> = <target>` — a correlation against the decision request.
|
|
150
|
+
*
|
|
151
|
+
* Upstream requires the `pin` prefix and the `= …` clause together, and a
|
|
152
|
+
* pinned field must be a leaf (never a record type), so both are enforced here
|
|
153
|
+
* rather than deferred to the parser.
|
|
154
|
+
*/
|
|
155
|
+
export function pinnedField(name: string, type: EventFieldType, target: PinTarget): NamedField {
|
|
156
|
+
if (type.type === "record") {
|
|
157
|
+
throw new Error(`dogwood: a pinned field must be a leaf, and "${name}" is a record type`);
|
|
158
|
+
}
|
|
159
|
+
return { field: "named", name: assertIdent(name, "a field name"), type, pin: target };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Declarations ──────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** `[decision] event <A>::kind { fields }`. */
|
|
165
|
+
export interface EventDeclaration {
|
|
166
|
+
/** `request`, `response`, `error`, or anything the author chooses. */
|
|
167
|
+
readonly kind: string;
|
|
168
|
+
/** True for the kind that carries the authorization decision. */
|
|
169
|
+
readonly decision?: boolean;
|
|
170
|
+
readonly fields: readonly EventField[];
|
|
171
|
+
/** Emitted as `// …` above the declaration. */
|
|
172
|
+
readonly comment?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One `event` declaration. */
|
|
176
|
+
export function eventDeclaration(
|
|
177
|
+
kind: string,
|
|
178
|
+
fields: EventField[],
|
|
179
|
+
options: { decision?: boolean; comment?: string } = {},
|
|
180
|
+
): EventDeclaration {
|
|
181
|
+
return { kind: assertIdent(kind, "an event kind"), fields, decision: options.decision, comment: options.comment };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A whole `.dwschema` file. */
|
|
185
|
+
export interface EventSchema {
|
|
186
|
+
/** `max_window = 30d`. Omitted means upstream's 24h default applies. */
|
|
187
|
+
readonly maxWindow?: TemporalWindow;
|
|
188
|
+
/** The action binder name, `A` by convention. */
|
|
189
|
+
readonly binder: string;
|
|
190
|
+
readonly events: readonly EventDeclaration[];
|
|
191
|
+
/** Header comment lines, emitted above everything. */
|
|
192
|
+
readonly comment?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Assemble a schema, defaulting the action binder to `A`. */
|
|
196
|
+
export function eventSchema(
|
|
197
|
+
events: EventDeclaration[],
|
|
198
|
+
options: { maxWindow?: WindowLike; binder?: string; comment?: string } = {},
|
|
199
|
+
): EventSchema {
|
|
200
|
+
if (events.length === 0) throw new Error("dogwood: an event schema declares at least one event kind");
|
|
201
|
+
const kinds = new Set<string>();
|
|
202
|
+
for (const e of events) {
|
|
203
|
+
if (kinds.has(e.kind)) throw new Error(`dogwood: event kind "${e.kind}" is declared twice`);
|
|
204
|
+
kinds.add(e.kind);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
maxWindow: options.maxWindow === undefined ? undefined : window(options.maxWindow),
|
|
208
|
+
binder: assertIdent(options.binder ?? "A", "an action binder"),
|
|
209
|
+
events,
|
|
210
|
+
comment: options.comment,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The default event-schema shape: `request` (deciding), `response` and `error`,
|
|
216
|
+
* each pinned to the request's principal.
|
|
217
|
+
*
|
|
218
|
+
* This reproduces `configuration/event-schemas/pinned.dwschema`, which is what
|
|
219
|
+
* `ServiceSchema::defaults()` uses. Emitting it explicitly is the honest thing
|
|
220
|
+
* to do the moment a project supplies *any* event schema, because supplying
|
|
221
|
+
* one opts out of the built-in default entirely.
|
|
222
|
+
*
|
|
223
|
+
* @param options.pinCallerPrincipal Drop the pin to allow cross-principal
|
|
224
|
+
* correlation. A deliberate choice: it widens every temporal predicate in the
|
|
225
|
+
* policy set, so it stamps a comment into the emitted file and DWDS010 reports
|
|
226
|
+
* it in the build.
|
|
227
|
+
*/
|
|
228
|
+
export function defaultEventSchema(
|
|
229
|
+
options: {
|
|
230
|
+
maxWindow?: WindowLike;
|
|
231
|
+
pinCallerPrincipal?: boolean;
|
|
232
|
+
principalField?: string;
|
|
233
|
+
binder?: string;
|
|
234
|
+
} = {},
|
|
235
|
+
): EventSchema {
|
|
236
|
+
const pinned = options.pinCallerPrincipal !== false;
|
|
237
|
+
const principalField = options.principalField ?? "callerPrincipal";
|
|
238
|
+
|
|
239
|
+
const common = (): EventField[] => [
|
|
240
|
+
pinned
|
|
241
|
+
? pinnedField(principalField, principalType(), pinPrincipal())
|
|
242
|
+
: field(principalField, principalType()),
|
|
243
|
+
field("callerResource", resourceType()),
|
|
244
|
+
field("requestId", concrete("String")),
|
|
245
|
+
field("sessionId", concrete("String")),
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
return eventSchema(
|
|
249
|
+
[
|
|
250
|
+
eventDeclaration("request", [spreadInputs(), ...common()], { decision: true }),
|
|
251
|
+
eventDeclaration("response", [spreadInputs(), spreadOutputs(), ...common()]),
|
|
252
|
+
eventDeclaration("error", [spreadInputs(), ...common()]),
|
|
253
|
+
],
|
|
254
|
+
{
|
|
255
|
+
maxWindow: options.maxWindow,
|
|
256
|
+
binder: options.binder,
|
|
257
|
+
comment: pinned
|
|
258
|
+
? "The default event-schema shape: request/response/error, each correlated to\nthe deciding request's principal."
|
|
259
|
+
: `Cross-principal: ${principalField} is NOT pinned, so temporal predicates see\nevents from every principal, not only the deciding request's. This widens\nevery predicate in the policy set and is a deliberate departure from\nupstream's default (see ServiceSchema::defaults).`,
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Rendering ─────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
function renderPinTarget(target: PinTarget): string {
|
|
267
|
+
return target.ref === "context" ? `context.${target.path.join(".")}` : [target.ref, ...target.attrs].join(".");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderFieldType(type: EventFieldType, binder: string, indent: string): string {
|
|
271
|
+
switch (type.type) {
|
|
272
|
+
case "selector":
|
|
273
|
+
return `${type.selector}(${binder})`;
|
|
274
|
+
case "concrete":
|
|
275
|
+
return type.name;
|
|
276
|
+
case "record":
|
|
277
|
+
return `{\n${renderFields(type.fields, binder, indent + " ")}\n${indent}}`;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderFields(fields: readonly EventField[], binder: string, indent: string): string {
|
|
282
|
+
return fields
|
|
283
|
+
.map((f) => {
|
|
284
|
+
if (f.field === "spread") return `${indent}...${f.selector}(${binder}),`;
|
|
285
|
+
const pin = f.pin ? ` = ${renderPinTarget(f.pin)}` : "";
|
|
286
|
+
return `${indent}${f.pin ? "pin " : ""}${f.name}: ${renderFieldType(f.type, binder, indent)}${pin},`;
|
|
287
|
+
})
|
|
288
|
+
.join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderComment(comment: string): string[] {
|
|
292
|
+
return comment.split("\n").map((line) => `// ${line}`.trimEnd());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Render a schema to `.dwschema` text. */
|
|
296
|
+
export function renderEventSchema(schema: EventSchema): string {
|
|
297
|
+
const lines: string[] = [];
|
|
298
|
+
if (schema.comment) lines.push(...renderComment(schema.comment), "");
|
|
299
|
+
|
|
300
|
+
if (schema.maxWindow) {
|
|
301
|
+
lines.push(`max_window = ${renderWindow(schema.maxWindow)}`, "");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const [index, event] of schema.events.entries()) {
|
|
305
|
+
if (index > 0) lines.push("");
|
|
306
|
+
if (event.comment) lines.push(...renderComment(event.comment));
|
|
307
|
+
lines.push(`${event.decision ? "decision " : ""}event <${schema.binder}>::${event.kind} {`);
|
|
308
|
+
if (event.fields.length > 0) lines.push(renderFields(event.fields, schema.binder, " "));
|
|
309
|
+
lines.push("}");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return lines.join("\n") + "\n";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Every event kind a schema declares, in declaration order. */
|
|
316
|
+
export function declaredEventKinds(schema: EventSchema): string[] {
|
|
317
|
+
return schema.events.map((e) => e.kind);
|
|
318
|
+
}
|