@intentius/chant-lexicon-cedar 0.44.9 → 0.44.12
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 +48 -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/avp/client.d.ts +85 -8
- package/dist/avp/client.d.ts.map +1 -1
- 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/dogwood/cli.d.ts +41 -0
- package/dist/dogwood/cli.d.ts.map +1 -1
- package/dist/dogwood/index.d.ts +10 -4
- package/dist/dogwood/index.d.ts.map +1 -1
- 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/serialize.d.ts +20 -0
- package/dist/dogwood/serialize.d.ts.map +1 -1
- package/dist/dogwood/trace.d.ts +215 -0
- package/dist/dogwood/trace.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- 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/index.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/DWDC013.md +15 -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/rules/dwdc013.ts +64 -0
- package/dist/skills/chant-cedar-dogwood.md +327 -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/avp/OWNERSHIP.md +38 -0
- package/src/avp/client.test.ts +271 -0
- package/src/avp/client.ts +150 -16
- package/src/codegen/docs-dogwood.ts +1119 -0
- package/src/codegen/docs.ts +66 -1
- package/src/dogwood/cli.test.ts +122 -1
- package/src/dogwood/cli.ts +122 -1
- package/src/dogwood/index.ts +74 -1
- 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/serialize.ts +37 -0
- package/src/dogwood/trace.test.ts +231 -0
- package/src/dogwood/trace.ts +471 -0
- package/src/index.ts +52 -0
- package/src/lint/audit-catalog.ts +8 -0
- package/src/lint/post-synth/dwd-post-synth.test.ts +119 -1
- package/src/lint/post-synth/dwdc013.ts +64 -0
- package/src/lint/post-synth/dwde-post-synth.test.ts +1 -1
- package/src/lint/post-synth/index.ts +2 -0
- package/src/op/activities/index.ts +27 -0
- package/src/plugin.test.ts +3 -2
- package/src/plugin.ts +30 -0
- package/src/skills/chant-cedar-dogwood.md +327 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PolicyReplayOp` — replay a declared `.dw` set against a recorded event
|
|
3
|
+
* trace and report divergence (#1661, epic #1646).
|
|
4
|
+
*
|
|
5
|
+
* Shape follows `WorkflowAuditOp`: an observe-dial Op whose last phase is a
|
|
6
|
+
* finding mode (`report | issue | pull-request`). What it observes is not a
|
|
7
|
+
* moving upstream but a moving *history* — a policy set that was correct
|
|
8
|
+
* against last week's traffic can decide differently against this week's, and
|
|
9
|
+
* the deterministic build cannot see that.
|
|
10
|
+
*
|
|
11
|
+
* ## Packaging: the composite ships from cedar, not from temporal
|
|
12
|
+
*
|
|
13
|
+
* `WorkflowAuditOp` and `PipelineAuditOp` live in the temporal lexicon because
|
|
14
|
+
* they hand back a `TemporalSchedule` alongside the Op, and that resource is
|
|
15
|
+
* temporal's. This composite hands back an Op and nothing else, so it has no
|
|
16
|
+
* reason to reach across — it imports `@intentius/chant/op` only, exactly as
|
|
17
|
+
* the fly lexicon's `flyDeploy` composite does. cedar therefore keeps zero
|
|
18
|
+
* runtime dependency on `@intentius/chant-lexicon-temporal`.
|
|
19
|
+
*
|
|
20
|
+
* The scheduled form is a two-line project-side pairing rather than a config
|
|
21
|
+
* flag, and that is the deliberate cost of the decision:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { TemporalSchedule } from "@intentius/chant-lexicon-temporal";
|
|
25
|
+
*
|
|
26
|
+
* export const { op } = PolicyReplayOp({ name: "policy-replay", … });
|
|
27
|
+
* export const schedule = new TemporalSchedule({
|
|
28
|
+
* scheduleId: "policy-replay-schedule",
|
|
29
|
+
* spec: { cronExpressions: ["0 6 * * *"] },
|
|
30
|
+
* action: { workflowType: "policyReplayWorkflow", taskQueue: "policy-replay" },
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* A project that wants that already installs the temporal lexicon; a project
|
|
35
|
+
* that only wants `chant run policy-replay` on the local executor should not
|
|
36
|
+
* have to. `examples/policy-replay` is the worked recipe for both.
|
|
37
|
+
*
|
|
38
|
+
* ## Phases
|
|
39
|
+
*
|
|
40
|
+
* ```
|
|
41
|
+
* Artifacts chantBuild — emit policies.dw, the .cedarschema and the
|
|
42
|
+
* .dwschema the replay reads (skippable when they are checked in)
|
|
43
|
+
* Replay dogwoodReplay — run `dogwood replay --format json` over the
|
|
44
|
+
* bundle and the trace, write the divergence report
|
|
45
|
+
* Report dogwoodReplayReport — read that report and act on the mode
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* The report file is the seam between the last two phases, the same way
|
|
49
|
+
* `dist/fly.json` is the seam between `build:fly` and `flyApply`: Op steps do
|
|
50
|
+
* not pass return values to one another, so a phase boundary needs an artifact
|
|
51
|
+
* to be a real boundary rather than a cosmetic one.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { Op, activity, build, phase, type ActivityStep, type OpResource } from "@intentius/chant/op";
|
|
55
|
+
import type { PolicyReplayMode, ReplayExpectation } from "./replay-activity";
|
|
56
|
+
|
|
57
|
+
/** Where the Replay phase writes its report by default. */
|
|
58
|
+
export const DEFAULT_REPLAY_REPORT_PATH = "dist/dogwood-replay.json";
|
|
59
|
+
|
|
60
|
+
/** Options for {@link dogwoodReplayStep}. Mirrors `DogwoodReplayArgs`. */
|
|
61
|
+
export interface DogwoodReplayStepOpts {
|
|
62
|
+
policies?: string;
|
|
63
|
+
policiesPath?: string;
|
|
64
|
+
policySchema?: string;
|
|
65
|
+
policySchemaPath?: string;
|
|
66
|
+
eventSchema?: string;
|
|
67
|
+
eventSchemaPath?: string;
|
|
68
|
+
macros?: string;
|
|
69
|
+
macrosPath?: string;
|
|
70
|
+
providers?: string;
|
|
71
|
+
providersPath?: string;
|
|
72
|
+
trace?: string;
|
|
73
|
+
tracePath?: string;
|
|
74
|
+
expect?: readonly ReplayExpectation[];
|
|
75
|
+
mode?: PolicyReplayMode;
|
|
76
|
+
binary?: string;
|
|
77
|
+
cwd?: string;
|
|
78
|
+
reportPath?: string;
|
|
79
|
+
profile?: ActivityStep["profile"];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Replay a policy bundle against a trace — the typed twin of `flyApplyStep`.
|
|
84
|
+
*
|
|
85
|
+
* Resolves to the `dogwoodReplay` activity by name, which `loadActivities`
|
|
86
|
+
* binds when the project lists the `cedar` lexicon. Defaults to the
|
|
87
|
+
* `policyCheck` profile.
|
|
88
|
+
*/
|
|
89
|
+
export const dogwoodReplayStep = (opts: DogwoodReplayStepOpts): ActivityStep => {
|
|
90
|
+
const { profile, ...args } = opts;
|
|
91
|
+
return activity("dogwoodReplay", args as Record<string, unknown>, profile ?? "policyCheck");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Options for {@link dogwoodReplayReportStep}. */
|
|
95
|
+
export interface DogwoodReplayReportStepOpts {
|
|
96
|
+
reportPath?: string;
|
|
97
|
+
mode?: PolicyReplayMode;
|
|
98
|
+
title?: string;
|
|
99
|
+
cwd?: string;
|
|
100
|
+
failOnDivergence?: boolean;
|
|
101
|
+
profile?: ActivityStep["profile"];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read a written replay report and act on its finding mode. */
|
|
105
|
+
export const dogwoodReplayReportStep = (opts: DogwoodReplayReportStepOpts = {}): ActivityStep => {
|
|
106
|
+
const { profile, ...args } = opts;
|
|
107
|
+
return activity("dogwoodReplayReport", args as Record<string, unknown>, profile ?? "fastIdempotent");
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Options for {@link PolicyReplayOp}. */
|
|
111
|
+
export interface PolicyReplayOpConfig {
|
|
112
|
+
/** Op name (kebab-case) — the `chant run <name>` target and the task queue base. */
|
|
113
|
+
name: string;
|
|
114
|
+
overview?: string;
|
|
115
|
+
/** Defaults to {@link name}. */
|
|
116
|
+
taskQueue?: string;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Directory every relative path below resolves against, and the one the
|
|
120
|
+
* build script runs in. Default `.`.
|
|
121
|
+
*/
|
|
122
|
+
path?: string;
|
|
123
|
+
/**
|
|
124
|
+
* npm script that emits the `.dw` bundle. Default `build`. Pass `false` when
|
|
125
|
+
* the artifacts are checked in — the Artifacts phase is then omitted rather
|
|
126
|
+
* than run as a no-op, so the Op's phase list says what it really does.
|
|
127
|
+
*/
|
|
128
|
+
buildScript?: string | false;
|
|
129
|
+
|
|
130
|
+
/** Emitted `.dw` policy set. Default `dist/policies.dw`. */
|
|
131
|
+
policiesPath?: string;
|
|
132
|
+
/** Emitted Cedar action schema. Required by the CLI; default `dist/app.cedarschema`. */
|
|
133
|
+
policySchemaPath?: string;
|
|
134
|
+
/** Emitted `.dwschema`, when the project declares one. */
|
|
135
|
+
eventSchemaPath?: string;
|
|
136
|
+
/** Emitted macro library, when the project ships one out of line. */
|
|
137
|
+
macrosPath?: string;
|
|
138
|
+
/** `providers.json`, with the Rhai inlined under `implementation.script`. */
|
|
139
|
+
providersPath?: string;
|
|
140
|
+
|
|
141
|
+
/** The recorded trace to replay. Required — there is nothing to replay without one. */
|
|
142
|
+
tracePath: string;
|
|
143
|
+
|
|
144
|
+
/** What each decision point must decide. Empty replays and reports verdicts. */
|
|
145
|
+
expect?: readonly ReplayExpectation[];
|
|
146
|
+
|
|
147
|
+
/** What to produce on divergence. Default `report`. */
|
|
148
|
+
onFinding?: PolicyReplayMode;
|
|
149
|
+
/** Title for the issue or pull request the finding mode calls for. */
|
|
150
|
+
title?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Fail the Op when the replay diverged. Default false — an observe-dial Op
|
|
153
|
+
* reports by default, and a red run is a decision the caller makes.
|
|
154
|
+
*/
|
|
155
|
+
failOnDivergence?: boolean;
|
|
156
|
+
|
|
157
|
+
/** Where the Replay phase writes its report. Default {@link DEFAULT_REPLAY_REPORT_PATH}. */
|
|
158
|
+
reportPath?: string;
|
|
159
|
+
/** Explicit `dogwood` binary path, for a runner that knows where it built one. */
|
|
160
|
+
binary?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What {@link PolicyReplayOp} hands back. */
|
|
164
|
+
export interface PolicyReplayOpResources {
|
|
165
|
+
/** The Op — discovered by `chant run <name>`, emitted by `chant build`. */
|
|
166
|
+
op: InstanceType<typeof OpResource>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Assemble the replay Op: emit the artifacts, replay them, act on the finding.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* export const { op } = PolicyReplayOp({
|
|
175
|
+
* name: "policy-replay",
|
|
176
|
+
* tracePath: "trace/read-after-login.log",
|
|
177
|
+
* expect: [
|
|
178
|
+
* { timestamp: 10, verdict: "allow", determiningRules: [0] },
|
|
179
|
+
* { timestamp: 7200, verdict: "deny", note: "the login is two hours stale" },
|
|
180
|
+
* ],
|
|
181
|
+
* onFinding: "issue",
|
|
182
|
+
* });
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
export function PolicyReplayOp(config: PolicyReplayOpConfig): PolicyReplayOpResources {
|
|
186
|
+
const path = config.path ?? ".";
|
|
187
|
+
const mode = config.onFinding ?? "report";
|
|
188
|
+
const reportPath = config.reportPath ?? DEFAULT_REPLAY_REPORT_PATH;
|
|
189
|
+
|
|
190
|
+
const phases = [];
|
|
191
|
+
|
|
192
|
+
if (config.buildScript !== false) {
|
|
193
|
+
phases.push(phase("Artifacts", [build(path, { script: config.buildScript ?? "build" })]));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
phases.push(
|
|
197
|
+
phase("Replay", [
|
|
198
|
+
{
|
|
199
|
+
...dogwoodReplayStep({
|
|
200
|
+
cwd: path,
|
|
201
|
+
policiesPath: config.policiesPath ?? "dist/policies.dw",
|
|
202
|
+
policySchemaPath: config.policySchemaPath ?? "dist/app.cedarschema",
|
|
203
|
+
...(config.eventSchemaPath ? { eventSchemaPath: config.eventSchemaPath } : {}),
|
|
204
|
+
...(config.macrosPath ? { macrosPath: config.macrosPath } : {}),
|
|
205
|
+
...(config.providersPath ? { providersPath: config.providersPath } : {}),
|
|
206
|
+
tracePath: config.tracePath,
|
|
207
|
+
...(config.expect ? { expect: config.expect } : {}),
|
|
208
|
+
mode,
|
|
209
|
+
...(config.binary ? { binary: config.binary } : {}),
|
|
210
|
+
reportPath,
|
|
211
|
+
}),
|
|
212
|
+
// The divergence count as a workflow search attribute, so "show me the
|
|
213
|
+
// replays that found something" is one filter rather than a log read.
|
|
214
|
+
outcomeAttribute: { name: "Divergences", from: "findings" },
|
|
215
|
+
},
|
|
216
|
+
]),
|
|
217
|
+
phase("Report", [
|
|
218
|
+
dogwoodReplayReportStep({
|
|
219
|
+
cwd: path,
|
|
220
|
+
reportPath,
|
|
221
|
+
mode,
|
|
222
|
+
...(config.title ? { title: config.title } : {}),
|
|
223
|
+
...(config.failOnDivergence ? { failOnDivergence: true } : {}),
|
|
224
|
+
}),
|
|
225
|
+
]),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
op: Op({
|
|
230
|
+
name: config.name,
|
|
231
|
+
overview:
|
|
232
|
+
config.overview ??
|
|
233
|
+
"Replay the declared dogwood policy set against a recorded event trace and report divergence",
|
|
234
|
+
taskQueue: config.taskQueue ?? config.name,
|
|
235
|
+
searchAttributes: {
|
|
236
|
+
Audit: "true",
|
|
237
|
+
Surface: "cedar-dogwood",
|
|
238
|
+
},
|
|
239
|
+
phases,
|
|
240
|
+
}),
|
|
241
|
+
};
|
|
242
|
+
}
|
package/src/dogwood/serialize.ts
CHANGED
|
@@ -207,3 +207,40 @@ export function serializeDogwood(entities: Map<string, Declarable>): DogwoodSeri
|
|
|
207
207
|
|
|
208
208
|
return { files, warnings };
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
/** One declared temporal policy, resolved: chant name, policy id, walked props. */
|
|
212
|
+
export interface DogwoodPolicyRecord {
|
|
213
|
+
/** The chant entity name — the export name on the `*.ts` file. */
|
|
214
|
+
name: string;
|
|
215
|
+
/** The policy id, as it appears in `@id(…)`. */
|
|
216
|
+
id: string;
|
|
217
|
+
/** Props with references resolved, ready for `renderTemporalPolicyText`. */
|
|
218
|
+
props: Record<string, unknown>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Every `Dogwood::TemporalPolicy` in a build, with references walked and ids
|
|
223
|
+
* resolved — the temporal counterpart of `cedarPolicyRecords` (#1660).
|
|
224
|
+
*
|
|
225
|
+
* `serializeDogwood` above renders whole *files*; the AgentCore embedding needs
|
|
226
|
+
* the policies one at a time, because `AWS::BedrockAgentCore::Policy` carries a
|
|
227
|
+
* single statement and `EnforcementMode` is set per policy. Both paths walk
|
|
228
|
+
* references through the same visitor, so a scope that names another declared
|
|
229
|
+
* entity resolves identically whichever one rendered it.
|
|
230
|
+
*/
|
|
231
|
+
export function dogwoodPolicyRecords(entities: Map<string, Declarable>): DogwoodPolicyRecord[] {
|
|
232
|
+
const entityNames = new Map<Declarable, string>();
|
|
233
|
+
for (const [name, entity] of entities) {
|
|
234
|
+
entityNames.set(entity, name);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const records: DogwoodPolicyRecord[] = [];
|
|
238
|
+
for (const [name, entity] of entities) {
|
|
239
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
240
|
+
if (entity.entityType !== DOGWOOD_POLICY_TYPE) continue;
|
|
241
|
+
|
|
242
|
+
const props = walkValue(getProps(entity), entityNames, dogwoodVisitor) as Record<string, unknown>;
|
|
243
|
+
records.push({ name, id: resolvePolicyId(name, props), props });
|
|
244
|
+
}
|
|
245
|
+
return records;
|
|
246
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace construction (#1661).
|
|
3
|
+
*
|
|
4
|
+
* The line grammar is #1657 §6, read from `interpreter/log_parse.rs` at the
|
|
5
|
+
* pinned SHA. The tests that matter most are not the rendering ones — they are
|
|
6
|
+
* the two traps, because both produce a *green* replay rather than a failing
|
|
7
|
+
* one: a field in only one bag, and an action name that is not fully
|
|
8
|
+
* qualified.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
auditTrace,
|
|
13
|
+
decimalValue,
|
|
14
|
+
entityRef,
|
|
15
|
+
rawValue,
|
|
16
|
+
renderTrace,
|
|
17
|
+
renderTraceLine,
|
|
18
|
+
renderTraceValue,
|
|
19
|
+
traceEntity,
|
|
20
|
+
traceEvent,
|
|
21
|
+
traceFixture,
|
|
22
|
+
} from "./trace";
|
|
23
|
+
|
|
24
|
+
const ALICE = 'Drupe::OAuthUser::"alice"';
|
|
25
|
+
const GATEWAY = 'Drupe::Gateway::"gw1"';
|
|
26
|
+
|
|
27
|
+
function login(over: Partial<Parameters<typeof traceEvent>[0]> = {}) {
|
|
28
|
+
return traceEvent({
|
|
29
|
+
timestamp: 0,
|
|
30
|
+
action: 'Drupe::Action::"Login"',
|
|
31
|
+
scope: { principal: ALICE, resource: GATEWAY },
|
|
32
|
+
context: { input: { user: "alice" } },
|
|
33
|
+
record: { callerPrincipal: entityRef(ALICE), requestId: "u1" },
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── The line grammar ───────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("renderTraceLine", () => {
|
|
41
|
+
test("renders the envelopes in the one order the parser accepts", () => {
|
|
42
|
+
const line = renderTraceLine(
|
|
43
|
+
traceEvent({
|
|
44
|
+
timestamp: 0,
|
|
45
|
+
action: 'Drupe::Action::"Login"',
|
|
46
|
+
scope: { principal: ALICE, resource: GATEWAY },
|
|
47
|
+
entities: [traceEntity(ALICE, { id: "alice" }, ['Drupe::Gateway::"gw1"'])],
|
|
48
|
+
context: { input: { user: "alice" } },
|
|
49
|
+
record: { requestId: "u1" },
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(line).toBe(
|
|
54
|
+
`@0 scope(principal: ${ALICE}, resource: ${GATEWAY}) ` +
|
|
55
|
+
`entities(${ALICE}: { id: "alice" } in [${GATEWAY}]) ` +
|
|
56
|
+
`request_context(input: { user: "alice" }) ` +
|
|
57
|
+
`Drupe::Action::"Login"::request(input: { user: "alice" }, requestId: "u1")`,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("every envelope is optional", () => {
|
|
62
|
+
const line = renderTraceLine({
|
|
63
|
+
timestamp: 42,
|
|
64
|
+
action: 'Drupe::Action::"Read"',
|
|
65
|
+
kind: "response",
|
|
66
|
+
record: { requestId: "u9" },
|
|
67
|
+
});
|
|
68
|
+
expect(line).toBe(`@42 Drupe::Action::"Read"::response(requestId: "u9")`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a whole trace is one event per line, newline-terminated", () => {
|
|
72
|
+
const text = renderTrace([login(), login({ timestamp: 10, kind: "response" })]);
|
|
73
|
+
expect(text.split("\n")).toHaveLength(3);
|
|
74
|
+
expect(text.endsWith("\n")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("values", () => {
|
|
79
|
+
test("Cedar surface forms, by JS type", () => {
|
|
80
|
+
expect(renderTraceValue("a")).toBe('"a"');
|
|
81
|
+
expect(renderTraceValue(7)).toBe("7");
|
|
82
|
+
expect(renderTraceValue(true)).toBe("true");
|
|
83
|
+
expect(renderTraceValue([1, "a"])).toBe('[1, "a"]');
|
|
84
|
+
expect(renderTraceValue({ a: 1 })).toBe("{ a: 1 }");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("an entity reference renders unquoted", () => {
|
|
88
|
+
expect(renderTraceValue(entityRef(ALICE))).toBe(ALICE);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a decimal keeps its scale, which a JS number cannot", () => {
|
|
92
|
+
expect(renderTraceValue(decimalValue("1.50"))).toBe("1.50");
|
|
93
|
+
// The failure mode this exists to prevent: 1.50 as a number is 1.5.
|
|
94
|
+
expect(() => renderTraceValue(1.5)).toThrow(/decimalValue/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("raw text passes through, for shapes this module has no type for", () => {
|
|
98
|
+
expect(renderTraceValue(rawValue("ip(\"10.0.0.1\")"))).toBe('ip("10.0.0.1")');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a value containing // survives, because the format has no comments", () => {
|
|
102
|
+
expect(renderTraceValue("https://example.test/a")).toBe('"https://example.test/a"');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("quotes and backslashes are escaped, not dropped", () => {
|
|
106
|
+
expect(renderTraceValue('a "b" \\ c')).toBe('"a \\"b\\" \\\\ c"');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Trap 1: the action must be fully qualified ─────────────────────
|
|
111
|
+
|
|
112
|
+
describe("fully-qualified actions", () => {
|
|
113
|
+
test("a short action name is rejected at construction", () => {
|
|
114
|
+
// The trap: a bare name leaves every temporal predicate unmatched while
|
|
115
|
+
// Cedar still authorizes, so the replay is green and tested nothing.
|
|
116
|
+
expect(() => traceEvent({ timestamp: 0, action: "Transfer", record: {} })).toThrow(
|
|
117
|
+
/fully qualified/,
|
|
118
|
+
);
|
|
119
|
+
expect(() => traceEvent({ timestamp: 0, action: '"Transfer"', record: {} })).toThrow(
|
|
120
|
+
/fully qualified/,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("so is a short entity reference, in scope or in value position", () => {
|
|
125
|
+
expect(() => entityRef("alice")).toThrow(/fully qualified/);
|
|
126
|
+
expect(() =>
|
|
127
|
+
traceEvent({
|
|
128
|
+
timestamp: 0,
|
|
129
|
+
action: 'Drupe::Action::"Read"',
|
|
130
|
+
scope: { principal: "alice", resource: GATEWAY },
|
|
131
|
+
record: {},
|
|
132
|
+
}),
|
|
133
|
+
).toThrow(/fully qualified/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── Trap 2: both bags ──────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("both-bag population", () => {
|
|
140
|
+
test("a context group is written to the envelope AND the logged record", () => {
|
|
141
|
+
const event = login();
|
|
142
|
+
expect(event.requestContext).toEqual({ input: { user: "alice" } });
|
|
143
|
+
expect(event.record).toMatchObject({ input: { user: "alice" } });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("record-only fields stay out of the Cedar request", () => {
|
|
147
|
+
const event = login();
|
|
148
|
+
expect(event.requestContext).not.toHaveProperty("requestId");
|
|
149
|
+
expect(event.record).toHaveProperty("requestId");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("the weaker forms exist, but have to be typed out", () => {
|
|
153
|
+
const recordOnly = login({ bags: "record-only" });
|
|
154
|
+
expect(recordOnly.requestContext).toBeUndefined();
|
|
155
|
+
expect(recordOnly.record).toHaveProperty("input");
|
|
156
|
+
|
|
157
|
+
const contextOnly = login({ bags: "context-only" });
|
|
158
|
+
expect(contextOnly.requestContext).toHaveProperty("input");
|
|
159
|
+
expect(contextOnly.record).not.toHaveProperty("input");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("auditTrace", () => {
|
|
164
|
+
test("a trace built the default way has nothing to report", () => {
|
|
165
|
+
expect(auditTrace([login(), login({ timestamp: 10 })])).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a record-only decision event is a single-bag finding", () => {
|
|
169
|
+
const issues = auditTrace([login({ bags: "record-only" })]);
|
|
170
|
+
expect(issues.map((i) => i.kind)).toContain("no-request-context");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a context-only decision event is a single-bag finding", () => {
|
|
174
|
+
const issues = auditTrace([login({ bags: "context-only" })]);
|
|
175
|
+
expect(issues).toHaveLength(1);
|
|
176
|
+
expect(issues[0].kind).toBe("single-bag");
|
|
177
|
+
expect(issues[0].message).toMatch(/temporal predicates over input\.\* silently miss/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a history-only event needs no request context — it never becomes one", () => {
|
|
181
|
+
// `response` does not decide under the default event schema, so an absent
|
|
182
|
+
// envelope there is not a weakening. Only the deciding kinds are held to it.
|
|
183
|
+
const historyOnly = traceEvent({
|
|
184
|
+
timestamp: 0,
|
|
185
|
+
action: 'Drupe::Action::"Login"',
|
|
186
|
+
kind: "response",
|
|
187
|
+
bags: "record-only",
|
|
188
|
+
context: { input: { user: "alice" } },
|
|
189
|
+
record: { requestId: "u1" },
|
|
190
|
+
});
|
|
191
|
+
expect(auditTrace([historyOnly])).toEqual([]);
|
|
192
|
+
// …but say `response` decides, and it is held to the same bar.
|
|
193
|
+
expect(auditTrace([historyOnly], { decisionKinds: ["response"] })).not.toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("an out-of-order timestamp is reported — history accumulates in file order", () => {
|
|
197
|
+
const issues = auditTrace([login({ timestamp: 10 }), login({ timestamp: 0 })]);
|
|
198
|
+
expect(issues.map((i) => i.kind)).toEqual(["out-of-order"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("an event that logs nothing can match no predicate", () => {
|
|
202
|
+
const empty = traceEvent({
|
|
203
|
+
timestamp: 0,
|
|
204
|
+
action: 'Drupe::Action::"Read"',
|
|
205
|
+
context: { input: { user: "alice" } },
|
|
206
|
+
bags: "context-only",
|
|
207
|
+
});
|
|
208
|
+
expect(auditTrace([empty]).map((i) => i.kind).sort()).toEqual(["empty-record", "single-bag"]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("traceFixture", () => {
|
|
213
|
+
test("hands back the text when the trace is honest", () => {
|
|
214
|
+
const fixture = traceFixture([login(), login({ timestamp: 10 })]);
|
|
215
|
+
expect(fixture.issues).toEqual([]);
|
|
216
|
+
expect(fixture.text.split("\n").filter(Boolean)).toHaveLength(2);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("refuses a fixture that would weaken its own replay", () => {
|
|
220
|
+
expect(() => traceFixture([login({ bags: "context-only" })])).toThrow(/weaken its own replay/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("…unless the weakening is named, which is the point of naming it", () => {
|
|
224
|
+
const fixture = traceFixture([login({ bags: "context-only" })], { allow: ["single-bag"] });
|
|
225
|
+
expect(fixture.issues.map((i) => i.kind)).toEqual(["single-bag"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("the error names the allow-list entry that would silence it", () => {
|
|
229
|
+
expect(() => traceFixture([login({ bags: "context-only" })])).toThrow(/allow: \["single-bag"\]/);
|
|
230
|
+
});
|
|
231
|
+
});
|