@intentius/chant-lexicon-cedar 0.106.4 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gate-policy-pack.d.ts +130 -0
- package/dist/gate-policy-pack.d.ts.map +1 -0
- package/dist/gate-policy.d.ts +8 -0
- package/dist/gate-policy.d.ts.map +1 -1
- package/dist/gate-schema.d.ts +54 -0
- package/dist/gate-schema.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/gate-policy-pack.test.ts +262 -0
- package/src/gate-policy-pack.ts +290 -0
- package/src/gate-policy.ts +2 -2
- package/src/gate-schema.ts +138 -0
- package/src/index.ts +15 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A starter policy pack for gates (chant#3182): rules a platform team writes
|
|
3
|
+
* once and puts on every gate, the way Sentinel or OPA policies run against a
|
|
4
|
+
* Terraform plan. Each builder returns one cedar `Policy` over the plan
|
|
5
|
+
* summary core puts in `context.plan` (`GatePlanSummary`, declared by
|
|
6
|
+
* `GATE_CEDAR_SCHEMA`), so an Op needs no context code of its own.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { gatePolicy, protectStateful, capDeletes, permitLowRiskAgents } from "@intentius/chant-lexicon-cedar";
|
|
10
|
+
*
|
|
11
|
+
* export const prodGate = gatePolicy("prod", [
|
|
12
|
+
* protectStateful(),
|
|
13
|
+
* capDeletes({ max: 5 }),
|
|
14
|
+
* permitLowRiskAgents({ maxChanges: 10 }),
|
|
15
|
+
* ]);
|
|
16
|
+
*
|
|
17
|
+
* gate("approve-prod", { plan: plan.out.planDigest, approval: { policy: prodGate, mode: "log-only" } });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* How the rules combine is Cedar's: a `forbid` beats every `permit`, and with
|
|
21
|
+
* no permit the answer is deny. The pack's forbids are the limits and its
|
|
22
|
+
* permits say when an agent may pass a gate on its own. A gate's quorum is
|
|
23
|
+
* unchanged by any of it, since a deny never removes a human's approval
|
|
24
|
+
* (`tallyGateApprovals`), and in `log-only` mode, the default, every decision
|
|
25
|
+
* is recorded and none binds, so a team can watch a rule against real plans
|
|
26
|
+
* before switching the gate to `enforce`.
|
|
27
|
+
*
|
|
28
|
+
* Every forbid fails closed: on a gate whose context has no `plan` (no change
|
|
29
|
+
* set behind the gate's `plan`), it applies. A limit nobody can check is not
|
|
30
|
+
* a reason to let an agent through.
|
|
31
|
+
*/
|
|
32
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
33
|
+
/**
|
|
34
|
+
* Resource types whose delete or replacement loses data: databases, buckets,
|
|
35
|
+
* volumes, queues with retention, and keys. Terraform-provider type names
|
|
36
|
+
* and the CloudFormation names the aws lexicon plans with.
|
|
37
|
+
*/
|
|
38
|
+
export declare const STATEFUL_TYPES: readonly string[];
|
|
39
|
+
/** Who a rule speaks about: agent principals only, or every approver. */
|
|
40
|
+
export type GatePackAppliesTo = "agents" | "everyone";
|
|
41
|
+
interface RuleBase {
|
|
42
|
+
/** The policy's `@id`, recorded in every decision it determines. Each builder has a default. */
|
|
43
|
+
id?: string;
|
|
44
|
+
/** Default `"everyone"` for a forbid. A human's approval still counts toward the quorum whatever a forbid says. */
|
|
45
|
+
appliesTo?: GatePackAppliesTo;
|
|
46
|
+
}
|
|
47
|
+
export interface CapDeletesOptions extends RuleBase {
|
|
48
|
+
/** The most deletes a plan may carry. Default 0. */
|
|
49
|
+
max?: number;
|
|
50
|
+
}
|
|
51
|
+
/** Forbid a plan that deletes more than `max` resources. Default id `cap-deletes`. */
|
|
52
|
+
export declare function capDeletes(options?: CapDeletesOptions): Declarable;
|
|
53
|
+
export interface CapReplacementsOptions extends RuleBase {
|
|
54
|
+
/** The most replacements a plan may carry. Default 0. */
|
|
55
|
+
max?: number;
|
|
56
|
+
}
|
|
57
|
+
/** Forbid a plan that replaces more than `max` resources. Default id `cap-replacements`. */
|
|
58
|
+
export declare function capReplacements(options?: CapReplacementsOptions): Declarable;
|
|
59
|
+
export interface ProtectStatefulOptions extends RuleBase {
|
|
60
|
+
/** The types that hold state. Default {@link STATEFUL_TYPES}. */
|
|
61
|
+
types?: readonly string[];
|
|
62
|
+
/** Forbid deletes of these types. Default true. */
|
|
63
|
+
deletes?: boolean;
|
|
64
|
+
/** Forbid replacements of these types, which delete the old object too. Default true. */
|
|
65
|
+
replacements?: boolean;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Forbid a plan that deletes or replaces a resource of a stateful type: an
|
|
69
|
+
* RDS instance, a DynamoDB table, a bucket. Default id `protect-stateful`.
|
|
70
|
+
*/
|
|
71
|
+
export declare function protectStateful(options?: ProtectStatefulOptions): Declarable;
|
|
72
|
+
export interface AllowRegionsOptions extends RuleBase {
|
|
73
|
+
regions: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
/** Forbid a plan that changes anything outside these regions. Default id `allow-regions`. */
|
|
76
|
+
export declare function allowRegions(options: AllowRegionsOptions): Declarable;
|
|
77
|
+
export interface RequireCreateTagsOptions extends RuleBase {
|
|
78
|
+
/** Tag (or label) keys every created resource must carry. */
|
|
79
|
+
keys: readonly string[];
|
|
80
|
+
/**
|
|
81
|
+
* Also forbid creates that carry no tags attribute at all. Default false,
|
|
82
|
+
* because many types have no tags (an IAM policy attachment, a route), and
|
|
83
|
+
* a create with tags left unset reads the same.
|
|
84
|
+
*/
|
|
85
|
+
strict?: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Forbid a plan whose tagged creates do not all carry `keys`. Reads
|
|
89
|
+
* `tags`, `tags_all` and `labels`. Default id `require-create-tags`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function requireCreateTags(options: RequireCreateTagsOptions): Declarable;
|
|
92
|
+
export interface PermitLowRiskAgentsOptions {
|
|
93
|
+
/** Default id `permit-low-risk-agents`. */
|
|
94
|
+
id?: string;
|
|
95
|
+
/** The most changes (creates, updates, replaces, deletes) a low-risk plan has. Default 10. */
|
|
96
|
+
maxChanges?: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Permit an agent to pass a gate on its own for a plan that deletes and
|
|
100
|
+
* replaces nothing, changes at most `maxChanges` resources, and planned
|
|
101
|
+
* every member in full (no failed member, no hole). In `enforce` mode that
|
|
102
|
+
* permit passes the gate; any forbid in the same set still wins.
|
|
103
|
+
*/
|
|
104
|
+
export declare function permitLowRiskAgents(options?: PermitLowRiskAgentsOptions): Declarable;
|
|
105
|
+
/** Permit an agent to pass a plan that only changes tags or labels. Default id `permit-tag-only-agents`. */
|
|
106
|
+
export declare function permitTagOnlyAgents(options?: {
|
|
107
|
+
id?: string;
|
|
108
|
+
}): Declarable;
|
|
109
|
+
export interface StarterGatePoliciesOptions {
|
|
110
|
+
/** {@link capDeletes}'s max. Default 0. */
|
|
111
|
+
maxDeletes?: number;
|
|
112
|
+
/** {@link capReplacements}'s max. Default: no cap beyond {@link protectStateful}. */
|
|
113
|
+
maxReplacements?: number;
|
|
114
|
+
/** {@link protectStateful}'s types. Default {@link STATEFUL_TYPES}. */
|
|
115
|
+
statefulTypes?: readonly string[];
|
|
116
|
+
/** {@link allowRegions}. Absent, regions are not limited. */
|
|
117
|
+
regions?: readonly string[];
|
|
118
|
+
/** {@link requireCreateTags}. Absent, tags are not required. */
|
|
119
|
+
requiredTags?: readonly string[];
|
|
120
|
+
/** {@link permitLowRiskAgents}'s maxChanges. Default 10. */
|
|
121
|
+
agentMaxChanges?: number;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The pack as one record for `gatePolicy(name, starterGatePolicies(...))`:
|
|
125
|
+
* stateful types protected, deletes capped, regions and tags when given,
|
|
126
|
+
* and the two agent permits.
|
|
127
|
+
*/
|
|
128
|
+
export declare function starterGatePolicies(options?: StarterGatePoliciesOptions): Record<string, Declarable>;
|
|
129
|
+
export {};
|
|
130
|
+
//# sourceMappingURL=gate-policy-pack.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gate-policy-pack.d.ts","sourceRoot":"","sources":["../src/gate-policy-pack.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAK9D;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAwB3C,CAAC;AAEF,yEAAyE;AACzE,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,UAAU,CAAC;AAEtD,UAAU,QAAQ;IAChB,gGAAgG;IAChG,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,mHAAmH;IACnH,SAAS,CAAC,EAAE,iBAAiB,CAAC;CAC/B;AAwCD,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,oDAAoD;IACpD,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,sFAAsF;AACtF,wBAAgB,UAAU,CAAC,OAAO,GAAE,iBAAsB,GAAG,UAAU,CAOtE;AAED,MAAM,WAAW,sBAAuB,SAAQ,QAAQ;IACtD,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,4FAA4F;AAC5F,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,UAAU,CAOhF;AAED,MAAM,WAAW,sBAAuB,SAAQ,QAAQ;IACtD,iEAAiE;IACjE,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,mDAAmD;IACnD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,yFAAyF;IACzF,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,UAAU,CAchF;AAED,MAAM,WAAW,mBAAoB,SAAQ,QAAQ;IACnD,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B;AAED,6FAA6F;AAC7F,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,UAAU,CAOrE;AAED,MAAM,WAAW,wBAAyB,SAAQ,QAAQ;IACxD,6DAA6D;IAC7D,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,UAAU,CAQ/E;AAED,MAAM,WAAW,0BAA0B;IACzC,2CAA2C;IAC3C,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,8FAA8F;IAC9F,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,0BAA+B,GAAG,UAAU,CAgBxF;AAED,4GAA4G;AAC5G,wBAAgB,mBAAmB,CAAC,OAAO,GAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,UAAU,CAW7E;AAED,MAAM,WAAW,0BAA0B;IACzC,2CAA2C;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qFAAqF;IACrF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,6DAA6D;IAC7D,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,gEAAgE;IAChE,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,0BAA+B,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAUxG"}
|
package/dist/gate-policy.d.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
28
28
|
import { type GatePolicyAnswer, type GatePolicyRef, type GatePolicyRequest } from "@intentius/chant/op";
|
|
29
|
+
import { loadWasm } from "./lint/post-synth/wasm-helpers.js";
|
|
29
30
|
export declare const GATE_HUMAN_TYPE = "Chant::Human";
|
|
30
31
|
export declare const GATE_AGENT_TYPE = "Chant::Agent";
|
|
31
32
|
export declare const GATE_ROLE_TYPE = "Chant::Role";
|
|
@@ -56,6 +57,13 @@ export type GatePolicies = Declarable | readonly Declarable[] | Readonly<Record<
|
|
|
56
57
|
* ```
|
|
57
58
|
*/
|
|
58
59
|
export declare function gatePolicy(name: string, policies: GatePolicies): GatePolicyRef;
|
|
60
|
+
/**
|
|
61
|
+
* The set as a record keyed by each policy's `@id`. Handed the text whole,
|
|
62
|
+
* cedar-wasm names policies by position (`policy0`), and a recorded decision
|
|
63
|
+
* would then name a rule nobody wrote. A policy with no `@id` keeps its
|
|
64
|
+
* positional name. Exported for `./gate-schema.ts`, so validation names rules the same way.
|
|
65
|
+
*/
|
|
66
|
+
export declare function keyedById(wasm: NonNullable<ReturnType<typeof loadWasm>>, policy: GatePolicyRef): Record<string, string>;
|
|
59
67
|
/**
|
|
60
68
|
* Evaluate one `PassGate` request. Called by core through
|
|
61
69
|
* `loadGatePolicyEvaluator("cedar")`; see the module doc for the entity model.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate-policy.d.ts","sourceRoot":"","sources":["../src/gate-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"gate-policy.d.ts","sourceRoot":"","sources":["../src/gate-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,EAAiB,QAAQ,EAAiB,MAAM,gCAAgC,CAAC;AAExF,eAAO,MAAM,eAAe,iBAAiB,CAAC;AAC9C,eAAO,MAAM,eAAe,iBAAiB,CAAC;AAC9C,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAC5C,eAAO,MAAM,kBAAkB,gBAAgB,CAAC;AAChD,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,gCAA8B,CAAC;AAE5D,iHAAiH;AACjH,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,SAAS,UAAU,EAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC;AAcrG;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,GAAG,aAAa,CA0B9E;AAsBD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,WAAW,CAAC,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAYvH;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CA+BtG"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cedar schema of a gate's `PassGate` request (chant#3182).
|
|
3
|
+
*
|
|
4
|
+
* {@link GATE_CEDAR_SCHEMA} declares the `Chant` namespace `evaluateGatePolicy`
|
|
5
|
+
* builds requests in (`./gate-policy.ts`), with the plan summary core puts in
|
|
6
|
+
* `context.plan` (`@intentius/chant/op`'s `GatePlanSummary`) as the common type
|
|
7
|
+
* `Chant::PlanSummary`. Two uses:
|
|
8
|
+
*
|
|
9
|
+
* - {@link validateGatePolicy} checks a gate policy set against it, so a rule
|
|
10
|
+
* that reads `context.plan.deleteCount` (a field that does not exist) fails
|
|
11
|
+
* where it is written rather than erroring on every request.
|
|
12
|
+
* - A project that writes gate policies with typed `Policy` scopes puts this
|
|
13
|
+
* text in its own `.cedarschema` (or hands {@link gateCedarSchema}'s output
|
|
14
|
+
* to `new Schema({ text })`), and the generated scope types then name
|
|
15
|
+
* `Chant::Agent` and `Chant::Action::"PassGate"` without a cast.
|
|
16
|
+
*
|
|
17
|
+
* A gate's own `approval.context` keys differ per gate, so the schema declares
|
|
18
|
+
* only `planDigest` and `plan` unless {@link gateCedarSchema} is told the rest.
|
|
19
|
+
* Every context attribute is optional: a gate with no plan, or a plan step
|
|
20
|
+
* that returned no change set, has neither.
|
|
21
|
+
*/
|
|
22
|
+
import type { GatePlanSummary, GatePolicyRef } from "@intentius/chant/op";
|
|
23
|
+
import { type ValidationFinding } from "./lint/post-synth/wasm-helpers.js";
|
|
24
|
+
/**
|
|
25
|
+
* Each `GatePlanSummary` field as a Cedar type. Typed as a record over the
|
|
26
|
+
* summary's keys, so a field added to the summary in core fails to compile
|
|
27
|
+
* here until the schema says what it is.
|
|
28
|
+
*/
|
|
29
|
+
export declare const GATE_PLAN_SUMMARY_CEDAR_TYPES: {
|
|
30
|
+
readonly [K in keyof GatePlanSummary]: string;
|
|
31
|
+
};
|
|
32
|
+
export interface GateCedarSchemaOptions {
|
|
33
|
+
/**
|
|
34
|
+
* The gate's own `approval.context` attributes, as Cedar types
|
|
35
|
+
* (`{ risk: "String", paths: "Set<String>" }`). Each is declared optional,
|
|
36
|
+
* since a step-output reference that resolves to nothing is left out.
|
|
37
|
+
*/
|
|
38
|
+
context?: Readonly<Record<string, string>>;
|
|
39
|
+
}
|
|
40
|
+
/** The schema text, with the gate's own context attributes when given. */
|
|
41
|
+
export declare function gateCedarSchema(options?: GateCedarSchemaOptions): string;
|
|
42
|
+
/** The schema with no gate-specific context: `planDigest` and `plan` only. */
|
|
43
|
+
export declare const GATE_CEDAR_SCHEMA: string;
|
|
44
|
+
export interface GatePolicyValidation {
|
|
45
|
+
errors: ValidationFinding[];
|
|
46
|
+
warnings: ValidationFinding[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validate a gate policy set against the gate schema. Throws when the wasm
|
|
50
|
+
* cannot be loaded or validation cannot run at all (a schema that does not
|
|
51
|
+
* parse); findings come back sorted.
|
|
52
|
+
*/
|
|
53
|
+
export declare function validateGatePolicy(policy: GatePolicyRef, options?: GateCedarSchemaOptions): GatePolicyValidation;
|
|
54
|
+
//# sourceMappingURL=gate-schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gate-schema.d.ts","sourceRoot":"","sources":["../src/gate-schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC1E,OAAO,EAA8C,KAAK,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAGpH;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,EAAE;IAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,eAAe,GAAG,MAAM;CA8B1F,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC5C;AAID,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,MAAM,CAyC5E;AAED,8EAA8E;AAC9E,eAAO,MAAM,iBAAiB,QAAoB,CAAC;AAEnD,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC5B,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,GAAE,sBAA2B,GAAG,oBAAoB,CAMpH"}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export { cedarSerializer, policyIdFromLogicalName, resolvePolicyId, cedarPolicyR
|
|
|
3
3
|
export { CEDAR_POLICY_TYPE, CEDAR_JSON_FILENAME } from "./serializer.js";
|
|
4
4
|
export { gatePolicy, evaluateGatePolicy, GATE_HUMAN_TYPE, GATE_AGENT_TYPE, GATE_ROLE_TYPE, GATE_RESOURCE_TYPE, PASS_GATE_ACTION, } from "./gate-policy.js";
|
|
5
5
|
export type { GatePolicies } from "./gate-policy.js";
|
|
6
|
+
export { GATE_CEDAR_SCHEMA, GATE_PLAN_SUMMARY_CEDAR_TYPES, gateCedarSchema, validateGatePolicy, } from "./gate-schema.js";
|
|
7
|
+
export type { GateCedarSchemaOptions, GatePolicyValidation } from "./gate-schema.js";
|
|
8
|
+
export { STATEFUL_TYPES, allowRegions, capDeletes, capReplacements, permitLowRiskAgents, permitTagOnlyAgents, protectStateful, requireCreateTags, starterGatePolicies, } from "./gate-policy-pack.js";
|
|
9
|
+
export type { AllowRegionsOptions, CapDeletesOptions, CapReplacementsOptions, GatePackAppliesTo, PermitLowRiskAgentsOptions, ProtectStatefulOptions, RequireCreateTagsOptions, StarterGatePoliciesOptions, } from "./gate-policy-pack.js";
|
|
6
10
|
export type { CedarEffect, CedarScope, CedarPolicyProps, CedarPolicyRecord } from "./serializer.js";
|
|
7
11
|
export { avpStatement, avpStatementJSON, avpPolicyDefinition, avpPolicyResource, avpPolicySet, } from "./avp/embed.js";
|
|
8
12
|
export type { AvpEmbedOptions, AvpPolicyDefinition, AvpPolicyResource, AvpStaticDefinition, } from "./avp/embed.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAGvC,OAAO,EAAE,eAAe,EAAE,uBAAuB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAC7G,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGtE,OAAO,EACL,UAAU,EAAE,kBAAkB,EAC9B,eAAe,EAAE,eAAe,EAAE,cAAc,EAAE,kBAAkB,EAAE,gBAAgB,GACvF,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "06acb88938cb2c036ad5d0031b53a01387657ebac95c6a50507f3c027362f5cf",
|
|
5
5
|
"meta.json": "b218779260169a193882aeee76e5ac559588f1682657a001fb0982cffb5843fc",
|
|
6
6
|
"types/index.d.ts": "9e53a1cb3c9ee38f9de2f3c0db9cc28a0b67c3649be410c341e2451b9b0076cd",
|
|
7
7
|
"rules/policy-shape.ts": "1654730d188b7163dbad3051c75a562198a96eda6d36ce35701f2b729bf45194",
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
"skills/chant-cedar-meta-policy.md": "050b310a87196a48c827f1129698e4c6bbcd96dc963782e22bf1a640bbc20a1a",
|
|
31
31
|
"skills/chant-cedar-dogwood.md": "7e52d3f045c1c34e00fdef76014a05483fb9dd58cc41037621a76bc298776d8f"
|
|
32
32
|
},
|
|
33
|
-
"composite": "
|
|
33
|
+
"composite": "80f662a22726fff34698a908c0bc926ec8abbd36226baa5eed1c07df4a64e119"
|
|
34
34
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-cedar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.108.0",
|
|
4
4
|
"description": "Cedar lexicon for chant — typed authoring for Cedar authorization policies",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
76
|
"zod": "^4.3.6",
|
|
77
|
-
"@intentius/chant": "^0.
|
|
77
|
+
"@intentius/chant": "^0.108.0",
|
|
78
78
|
"typescript": "^5.9.3"
|
|
79
79
|
}
|
|
80
80
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The starter gate policy pack and the gate's Cedar schema (chant#3182),
|
|
3
|
+
* evaluated by the real cedar-wasm against plan summaries core builds from
|
|
4
|
+
* change sets. The Done-when of #3182 is the first describe: a gate denies an
|
|
5
|
+
* agent's approval of a plan that deletes an RDS instance, with no context
|
|
6
|
+
* code in the Op, using a policy from the pack.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, expect } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
gatePlanSummary, gatePolicyRequest, loadGatePolicyEvaluator, runOpLocally, stepOutput,
|
|
11
|
+
type ActivityFn, type GateApproval, type GateLedgerPort, type GatePolicyDecision, type OpConfig,
|
|
12
|
+
} from "@intentius/chant/op";
|
|
13
|
+
import type { ChangeSetEntry, ChangeSetPart } from "@intentius/chant/change-set";
|
|
14
|
+
import type { GateResolutionRecord, PendingGateRecord } from "@intentius/chant/lifecycle/gate-ledger";
|
|
15
|
+
import { Policy } from "./generated/index";
|
|
16
|
+
import { evaluateGatePolicy, gatePolicy } from "./gate-policy";
|
|
17
|
+
import {
|
|
18
|
+
allowRegions, capDeletes, capReplacements, permitLowRiskAgents, permitTagOnlyAgents, protectStateful,
|
|
19
|
+
requireCreateTags, starterGatePolicies,
|
|
20
|
+
} from "./gate-policy-pack";
|
|
21
|
+
import { GATE_CEDAR_SCHEMA, GATE_PLAN_SUMMARY_CEDAR_TYPES, gateCedarSchema, validateGatePolicy } from "./gate-schema";
|
|
22
|
+
|
|
23
|
+
const DIGEST = `sha256:${"b".repeat(64)}`;
|
|
24
|
+
|
|
25
|
+
function entry(address: string, action: ChangeSetEntry["action"], extra: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
|
|
26
|
+
return { member: "estate", lexicon: "terraform", planner: "terraform", address, type: address.split(".")[0], action, attributes: [], ...extra };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function part(entries: ChangeSetEntry[]): ChangeSetPart {
|
|
30
|
+
return {
|
|
31
|
+
member: { member: "estate", lexicon: "terraform", planner: "terraform", status: "planned", planDigest: DIGEST, holes: [] },
|
|
32
|
+
entries,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DELETES_RDS = part([
|
|
37
|
+
entry("aws_db_instance.orders", "delete", { disruption: "destroy", region: "us-east-1" }),
|
|
38
|
+
entry("aws_security_group.db", "update", { disruption: "in-place", region: "us-east-1", attributes: [{ path: "description", after: "x" }] }),
|
|
39
|
+
]);
|
|
40
|
+
const ADDS_A_BUCKET = part([
|
|
41
|
+
entry("aws_s3_bucket.assets", "create", { region: "us-east-1", attributes: [{ path: "tags", after: { owner: "web", env: "prod" } }] }),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function ask(kind: "human" | "agent", plan: ChangeSetPart | undefined, extra: Record<string, unknown> = {}) {
|
|
45
|
+
return gatePolicyRequest({
|
|
46
|
+
op: "estate-apply", gate: "approve", resolvedBy: kind === "agent" ? "deploy-bot" : "alex",
|
|
47
|
+
approver: { kind, roles: [] }, planDigest: DIGEST,
|
|
48
|
+
context: { ...extra, ...(plan ? { plan: gatePlanSummary({ members: [plan.member], entries: plan.entries }) } : {}) },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A ledger in memory that a test appends approvals to between runs. */
|
|
53
|
+
function memoryLedger() {
|
|
54
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
55
|
+
const pending: PendingGateRecord[] = [];
|
|
56
|
+
const port: GateLedgerPort = {
|
|
57
|
+
async read() { return { resolutions: [...resolutions], pending: [...pending] }; },
|
|
58
|
+
async appendPending(input) {
|
|
59
|
+
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
60
|
+
pending.push(record);
|
|
61
|
+
return { record, pushed: true };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
return { port, resolutions, pending };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** An Op a team writes once: plan, a gate on the plan with the pack, apply. Nothing in it computes context. */
|
|
68
|
+
function estateApply(approval: GateApproval): OpConfig {
|
|
69
|
+
return {
|
|
70
|
+
name: "estate-apply",
|
|
71
|
+
overview: "",
|
|
72
|
+
phases: [
|
|
73
|
+
{ name: "Plan", steps: [{ kind: "activity", fn: "planEstate", id: "plan", args: {} }] },
|
|
74
|
+
{ name: "Gate", steps: [{ kind: "gate", gate: "approve", plan: stepOutput("plan", "planDigest"), approval }] },
|
|
75
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "applyEstate", args: {} }] },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What `chant approve --actor deploy-bot --agent` does for a gate with a
|
|
82
|
+
* policy: evaluate it against the context the gated run recorded, through the
|
|
83
|
+
* same loader, and record the decision next to the approval.
|
|
84
|
+
*/
|
|
85
|
+
async function agentApproves(ledger: ReturnType<typeof memoryLedger>, at: string): Promise<GatePolicyDecision> {
|
|
86
|
+
const standing = ledger.pending[ledger.pending.length - 1];
|
|
87
|
+
const approval = standing.approval!;
|
|
88
|
+
const evaluator = await loadGatePolicyEvaluator(approval.policy!.lexicon);
|
|
89
|
+
const answer = await evaluator.evaluateGatePolicy(approval.policy!, gatePolicyRequest({
|
|
90
|
+
op: standing.op, gate: standing.gate, resolvedBy: "deploy-bot", approver: { kind: "agent" },
|
|
91
|
+
...(standing.planDigest ? { planDigest: standing.planDigest } : {}),
|
|
92
|
+
...(approval.context ? { context: approval.context } : {}),
|
|
93
|
+
}));
|
|
94
|
+
const policyDecision: GatePolicyDecision = { policy: approval.policy!.name, version: approval.policy!.version, mode: approval.mode, ...answer };
|
|
95
|
+
ledger.resolutions.push({
|
|
96
|
+
version: 1, op: standing.op, gate: standing.gate, resolvedBy: "deploy-bot", timestamp: at,
|
|
97
|
+
approver: { kind: "agent" }, policyDecision, ...(standing.planDigest ? { planDigest: standing.planDigest } : {}),
|
|
98
|
+
});
|
|
99
|
+
return policyDecision;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("#3182 Done when: a gate denies an agent's approval of a plan that deletes an RDS instance", () => {
|
|
103
|
+
const policy = gatePolicy("prod", starterGatePolicies());
|
|
104
|
+
|
|
105
|
+
function activitiesFor(plan: ChangeSetPart, applied: string[]) {
|
|
106
|
+
return new Map<string, ActivityFn>([
|
|
107
|
+
// terraformPlan's result shape: the digest the gate binds, and the root's change-set part.
|
|
108
|
+
["planEstate", async () => ({ planDigest: plan.member.planDigest, changeSet: plan })],
|
|
109
|
+
["applyEstate", async () => { applied.push("apply"); return {}; }],
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
test("enforce: the agent's approval is denied by protect-stateful and the run stays gated", async () => {
|
|
114
|
+
const ledger = memoryLedger();
|
|
115
|
+
const applied: string[] = [];
|
|
116
|
+
const op = estateApply({ policy, mode: "enforce" });
|
|
117
|
+
|
|
118
|
+
const first = await runOpLocally(op, activitiesFor(DELETES_RDS, applied), {}, undefined, { gates: ledger.port, now: "2026-10-06T12:00:00.000Z" });
|
|
119
|
+
expect(first.status).toBe("gated");
|
|
120
|
+
const recorded = ledger.pending[0].approval?.context?.plan as { deletedTypes: string[] };
|
|
121
|
+
expect(recorded.deletedTypes).toEqual(["aws_db_instance"]);
|
|
122
|
+
|
|
123
|
+
const decision = await agentApproves(ledger, "2026-10-06T12:01:00.000Z");
|
|
124
|
+
expect(decision.decision).toBe("deny");
|
|
125
|
+
expect(decision.determining).toEqual(["cap-deletes", "protect-stateful"]);
|
|
126
|
+
|
|
127
|
+
const second = await runOpLocally(op, activitiesFor(DELETES_RDS, applied), {}, undefined, { gates: ledger.port, now: "2026-10-06T12:02:00.000Z" });
|
|
128
|
+
expect(second.status).toBe("gated");
|
|
129
|
+
expect(applied).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("enforce: the same Op and pack let the agent through a plan that only adds a bucket", async () => {
|
|
133
|
+
const ledger = memoryLedger();
|
|
134
|
+
const applied: string[] = [];
|
|
135
|
+
const op = estateApply({ policy, mode: "enforce" });
|
|
136
|
+
|
|
137
|
+
await runOpLocally(op, activitiesFor(ADDS_A_BUCKET, applied), {}, undefined, { gates: ledger.port, now: "2026-10-06T12:00:00.000Z" });
|
|
138
|
+
const decision = await agentApproves(ledger, "2026-10-06T12:01:00.000Z");
|
|
139
|
+
expect(decision).toMatchObject({ decision: "allow", determining: ["permit-low-risk-agents"] });
|
|
140
|
+
|
|
141
|
+
const second = await runOpLocally(op, activitiesFor(ADDS_A_BUCKET, applied), {}, undefined, { gates: ledger.port, now: "2026-10-06T12:02:00.000Z" });
|
|
142
|
+
expect(second.status).toBe("ok");
|
|
143
|
+
expect(second.records.find((r) => r.fn === "gate:approve")?.approval).toMatchObject({ resolvedBy: "deploy-bot", via: "policy" });
|
|
144
|
+
expect(applied).toEqual(["apply"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("log-only: the deny is recorded and nothing binds, so the agent alone does not pass and the team sees why", async () => {
|
|
148
|
+
const ledger = memoryLedger();
|
|
149
|
+
const applied: string[] = [];
|
|
150
|
+
const op = estateApply({ policy, mode: "log-only" });
|
|
151
|
+
await runOpLocally(op, activitiesFor(ADDS_A_BUCKET, applied), {}, undefined, { gates: ledger.port, now: "2026-10-06T12:00:00.000Z" });
|
|
152
|
+
const decision = await agentApproves(ledger, "2026-10-06T12:01:00.000Z");
|
|
153
|
+
expect(decision).toMatchObject({ decision: "allow", mode: "log-only" });
|
|
154
|
+
const second = await runOpLocally(op, activitiesFor(ADDS_A_BUCKET, applied), {}, undefined, { gates: ledger.port, now: "2026-10-06T12:02:00.000Z" });
|
|
155
|
+
expect(second.status).toBe("gated");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("the pack's rules (#3182)", () => {
|
|
160
|
+
const evaluate = (policies: Parameters<typeof gatePolicy>[1], kind: "human" | "agent", plan: ChangeSetPart | undefined) =>
|
|
161
|
+
evaluateGatePolicy(gatePolicy("pack", policies), ask(kind, plan));
|
|
162
|
+
const permitAll = permitLowRiskAgents({ maxChanges: 1000 });
|
|
163
|
+
|
|
164
|
+
test("protectStateful forbids deleting or replacing a stateful type, and lets other changes by", () => {
|
|
165
|
+
expect(evaluate([protectStateful(), permitAll], "agent", DELETES_RDS)).toMatchObject({ decision: "deny", determining: ["protect-stateful"] });
|
|
166
|
+
const replacesDynamo = part([entry("aws_dynamodb_table.t", "replace", { disruption: "destroy" })]);
|
|
167
|
+
expect(evaluate([protectStateful(), permitLowRiskAgents()], "agent", replacesDynamo).decision).toBe("deny");
|
|
168
|
+
expect(evaluate([protectStateful({ replacements: false }), capReplacements({ max: 5 })], "agent", replacesDynamo).determining).toEqual([]);
|
|
169
|
+
expect(evaluate([protectStateful(), permitAll], "agent", ADDS_A_BUCKET).decision).toBe("allow");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("protectStateful reads the aws lexicon's CloudFormation type names too", () => {
|
|
173
|
+
const cfn = part([entry("OrdersDb", "delete", { type: "AWS::RDS::DBInstance", lexicon: "aws", planner: "chant" })]);
|
|
174
|
+
expect(evaluate([protectStateful(), permitAll], "agent", cfn).determining).toEqual(["protect-stateful"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("capDeletes and capReplacements forbid past their max", () => {
|
|
178
|
+
const twoDeletes = part([entry("aws_route.a", "delete"), entry("aws_route.b", "delete")]);
|
|
179
|
+
expect(evaluate([capDeletes({ max: 1 })], "agent", twoDeletes).determining).toEqual(["cap-deletes"]);
|
|
180
|
+
expect(evaluate([capDeletes({ max: 2 })], "agent", twoDeletes).determining).toEqual([]);
|
|
181
|
+
const replace = part([entry("aws_instance.web", "replace")]);
|
|
182
|
+
expect(evaluate([capReplacements()], "agent", replace).determining).toEqual(["cap-replacements"]);
|
|
183
|
+
expect(() => capDeletes({ max: -1 })).toThrow("at least 0");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("allowRegions forbids a change outside the list", () => {
|
|
187
|
+
const eu = part([entry("aws_instance.web", "create", { region: "eu-west-1" })]);
|
|
188
|
+
const rule = allowRegions({ regions: ["us-east-1", "us-west-2"] });
|
|
189
|
+
expect(evaluate([rule], "agent", eu).determining).toEqual(["allow-regions"]);
|
|
190
|
+
expect(evaluate([rule], "agent", ADDS_A_BUCKET).determining).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("requireCreateTags forbids a tagged create missing a key, and strict also forbids untagged creates", () => {
|
|
194
|
+
const rule = requireCreateTags({ keys: ["owner", "env"] });
|
|
195
|
+
expect(evaluate([rule], "agent", ADDS_A_BUCKET).determining).toEqual([]);
|
|
196
|
+
const noEnv = part([entry("aws_s3_bucket.b", "create", { attributes: [{ path: "tags", after: { owner: "web" } }] })]);
|
|
197
|
+
expect(evaluate([rule], "agent", noEnv).determining).toEqual(["require-create-tags"]);
|
|
198
|
+
const untagged = part([entry("aws_s3_bucket.c", "create")]);
|
|
199
|
+
expect(evaluate([rule], "agent", untagged).determining).toEqual([]);
|
|
200
|
+
expect(evaluate([requireCreateTags({ keys: ["owner"], strict: true })], "agent", untagged).determining).toEqual(["require-create-tags"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("permitLowRiskAgents permits an agent only, and only within its limits", () => {
|
|
204
|
+
expect(evaluate([permitLowRiskAgents()], "agent", ADDS_A_BUCKET).decision).toBe("allow");
|
|
205
|
+
expect(evaluate([permitLowRiskAgents()], "human", ADDS_A_BUCKET).decision).toBe("deny");
|
|
206
|
+
expect(evaluate([permitLowRiskAgents()], "agent", DELETES_RDS).decision).toBe("deny");
|
|
207
|
+
const many = part(Array.from({ length: 4 }, (_, i) => entry(`aws_route.r${i}`, "create")));
|
|
208
|
+
expect(evaluate([permitLowRiskAgents({ maxChanges: 3 })], "agent", many).decision).toBe("deny");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("permitTagOnlyAgents permits a plan that only retags", () => {
|
|
212
|
+
const retag = part([entry("aws_instance.web", "update", { attributes: [{ path: "tags", before: { a: "1" }, after: { a: "2" } }] })]);
|
|
213
|
+
expect(evaluate([permitTagOnlyAgents()], "agent", retag)).toMatchObject({ decision: "allow", determining: ["permit-tag-only-agents"] });
|
|
214
|
+
expect(evaluate([permitTagOnlyAgents()], "agent", ADDS_A_BUCKET).decision).toBe("deny");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("every forbid fails closed on a request with no plan summary, and the permits do not apply", () => {
|
|
218
|
+
for (const rule of [protectStateful(), capDeletes(), capReplacements(), allowRegions({ regions: ["us-east-1"] }), requireCreateTags({ keys: ["owner"] })]) {
|
|
219
|
+
const answer = evaluate([rule, permitTagOnlyAgents()], "agent", undefined);
|
|
220
|
+
expect(answer.decision).toBe("deny");
|
|
221
|
+
expect(answer.determining).toHaveLength(1);
|
|
222
|
+
expect(answer.errors).toEqual([]);
|
|
223
|
+
}
|
|
224
|
+
expect(evaluate([permitLowRiskAgents()], "agent", undefined)).toMatchObject({ decision: "deny", determining: [], errors: [] });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("appliesTo agents leaves a human's decision alone", () => {
|
|
228
|
+
expect(evaluate([capDeletes({ appliesTo: "agents" }), permitLowRiskAgents()], "human", DELETES_RDS).determining).toEqual([]);
|
|
229
|
+
expect(evaluate([capDeletes(), permitLowRiskAgents()], "human", DELETES_RDS).determining).toEqual(["cap-deletes"]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("the gate's Cedar schema (#3182)", () => {
|
|
234
|
+
test("declares every field of the plan summary core builds", () => {
|
|
235
|
+
const summary = gatePlanSummary({ members: [DELETES_RDS.member], entries: DELETES_RDS.entries });
|
|
236
|
+
expect(Object.keys(GATE_PLAN_SUMMARY_CEDAR_TYPES).sort()).toEqual(Object.keys(summary).sort());
|
|
237
|
+
for (const key of Object.keys(summary)) expect(GATE_CEDAR_SCHEMA).toContain(`"${key}": `);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("every rule in the pack validates against it", () => {
|
|
241
|
+
const all = gatePolicy("all", {
|
|
242
|
+
...starterGatePolicies({ maxReplacements: 2, regions: ["us-east-1"], requiredTags: ["owner"] }),
|
|
243
|
+
strictTags: requireCreateTags({ id: "strict-tags", keys: ["owner"], strict: true }),
|
|
244
|
+
agentDeletes: capDeletes({ id: "agent-deletes", appliesTo: "agents" }),
|
|
245
|
+
});
|
|
246
|
+
expect(validateGatePolicy(all)).toEqual({ errors: [], warnings: [] });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a rule that reads a field the summary does not have fails validation", () => {
|
|
250
|
+
const typo = gatePolicy("typo", { typo: new Policy({ effect: "forbid", when: ["context has plan && context.plan.deleteCount > 0"] }) });
|
|
251
|
+
expect(validateGatePolicy(typo).errors.map((e) => e.policyId)).toEqual(["typo"]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a gate's own context attributes are declared when given", () => {
|
|
255
|
+
const risky = gatePolicy("risk", { risk: new Policy({ effect: "forbid", when: ['context has risk && context.risk == "high"'] }) });
|
|
256
|
+
// Undeclared, the validator reports the rule (as an error or as an impossible policy).
|
|
257
|
+
const undeclared = validateGatePolicy(risky);
|
|
258
|
+
expect(undeclared.errors.length + undeclared.warnings.length).toBeGreaterThan(0);
|
|
259
|
+
expect(validateGatePolicy(risky, { context: { risk: "String" } })).toEqual({ errors: [], warnings: [] });
|
|
260
|
+
expect(() => gateCedarSchema({ context: { plan: "String" } })).toThrow("chant's own");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A starter policy pack for gates (chant#3182): rules a platform team writes
|
|
3
|
+
* once and puts on every gate, the way Sentinel or OPA policies run against a
|
|
4
|
+
* Terraform plan. Each builder returns one cedar `Policy` over the plan
|
|
5
|
+
* summary core puts in `context.plan` (`GatePlanSummary`, declared by
|
|
6
|
+
* `GATE_CEDAR_SCHEMA`), so an Op needs no context code of its own.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { gatePolicy, protectStateful, capDeletes, permitLowRiskAgents } from "@intentius/chant-lexicon-cedar";
|
|
10
|
+
*
|
|
11
|
+
* export const prodGate = gatePolicy("prod", [
|
|
12
|
+
* protectStateful(),
|
|
13
|
+
* capDeletes({ max: 5 }),
|
|
14
|
+
* permitLowRiskAgents({ maxChanges: 10 }),
|
|
15
|
+
* ]);
|
|
16
|
+
*
|
|
17
|
+
* gate("approve-prod", { plan: plan.out.planDigest, approval: { policy: prodGate, mode: "log-only" } });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* How the rules combine is Cedar's: a `forbid` beats every `permit`, and with
|
|
21
|
+
* no permit the answer is deny. The pack's forbids are the limits and its
|
|
22
|
+
* permits say when an agent may pass a gate on its own. A gate's quorum is
|
|
23
|
+
* unchanged by any of it, since a deny never removes a human's approval
|
|
24
|
+
* (`tallyGateApprovals`), and in `log-only` mode, the default, every decision
|
|
25
|
+
* is recorded and none binds, so a team can watch a rule against real plans
|
|
26
|
+
* before switching the gate to `enforce`.
|
|
27
|
+
*
|
|
28
|
+
* Every forbid fails closed: on a gate whose context has no `plan` (no change
|
|
29
|
+
* set behind the gate's `plan`), it applies. A limit nobody can check is not
|
|
30
|
+
* a reason to let an agent through.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
34
|
+
import { Policy, type EntityTypeName, type PolicyRef, type PolicyScope } from "./generated/index";
|
|
35
|
+
import { escapeCedarString } from "./policy-text";
|
|
36
|
+
import { GATE_AGENT_TYPE, PASS_GATE_ACTION } from "./gate-policy";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resource types whose delete or replacement loses data: databases, buckets,
|
|
40
|
+
* volumes, queues with retention, and keys. Terraform-provider type names
|
|
41
|
+
* and the CloudFormation names the aws lexicon plans with.
|
|
42
|
+
*/
|
|
43
|
+
export const STATEFUL_TYPES: readonly string[] = [
|
|
44
|
+
// AWS, terraform
|
|
45
|
+
"aws_db_instance", "aws_rds_cluster", "aws_rds_cluster_instance", "aws_rds_global_cluster",
|
|
46
|
+
"aws_dynamodb_table", "aws_dynamodb_global_table", "aws_s3_bucket", "aws_efs_file_system",
|
|
47
|
+
"aws_ebs_volume", "aws_elasticache_cluster", "aws_elasticache_replication_group",
|
|
48
|
+
"aws_docdb_cluster", "aws_neptune_cluster", "aws_redshift_cluster", "aws_opensearch_domain",
|
|
49
|
+
"aws_elasticsearch_domain", "aws_msk_cluster", "aws_kinesis_stream", "aws_sqs_queue",
|
|
50
|
+
"aws_kms_key", "aws_secretsmanager_secret", "aws_backup_vault", "aws_fsx_lustre_file_system",
|
|
51
|
+
// AWS, CloudFormation
|
|
52
|
+
"AWS::RDS::DBInstance", "AWS::RDS::DBCluster", "AWS::RDS::GlobalCluster", "AWS::DynamoDB::Table",
|
|
53
|
+
"AWS::DynamoDB::GlobalTable", "AWS::S3::Bucket", "AWS::EFS::FileSystem", "AWS::EC2::Volume",
|
|
54
|
+
"AWS::ElastiCache::CacheCluster", "AWS::ElastiCache::ReplicationGroup", "AWS::DocDB::DBCluster",
|
|
55
|
+
"AWS::Neptune::DBCluster", "AWS::Redshift::Cluster", "AWS::OpenSearchService::Domain",
|
|
56
|
+
"AWS::MSK::Cluster", "AWS::Kinesis::Stream", "AWS::SQS::Queue", "AWS::KMS::Key",
|
|
57
|
+
"AWS::SecretsManager::Secret", "AWS::Backup::BackupVault",
|
|
58
|
+
// Google Cloud
|
|
59
|
+
"google_sql_database_instance", "google_sql_database", "google_storage_bucket",
|
|
60
|
+
"google_bigquery_dataset", "google_bigquery_table", "google_spanner_instance",
|
|
61
|
+
"google_spanner_database", "google_compute_disk", "google_filestore_instance",
|
|
62
|
+
"google_redis_instance", "google_kms_crypto_key", "google_bigtable_instance",
|
|
63
|
+
// Azure
|
|
64
|
+
"azurerm_mssql_server", "azurerm_mssql_database", "azurerm_postgresql_flexible_server",
|
|
65
|
+
"azurerm_mysql_flexible_server", "azurerm_storage_account", "azurerm_cosmosdb_account",
|
|
66
|
+
"azurerm_managed_disk", "azurerm_redis_cache", "azurerm_key_vault",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Who a rule speaks about: agent principals only, or every approver. */
|
|
70
|
+
export type GatePackAppliesTo = "agents" | "everyone";
|
|
71
|
+
|
|
72
|
+
interface RuleBase {
|
|
73
|
+
/** The policy's `@id`, recorded in every decision it determines. Each builder has a default. */
|
|
74
|
+
id?: string;
|
|
75
|
+
/** Default `"everyone"` for a forbid. A human's approval still counts toward the quorum whatever a forbid says. */
|
|
76
|
+
appliesTo?: GatePackAppliesTo;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The generated scope types are narrowed to the project's own schema; these
|
|
80
|
+
// names are the gate schema's (`GATE_CEDAR_SCHEMA`).
|
|
81
|
+
const AGENT = GATE_AGENT_TYPE as EntityTypeName;
|
|
82
|
+
const PASS_GATE = PASS_GATE_ACTION as unknown as PolicyRef;
|
|
83
|
+
|
|
84
|
+
const set = (values: readonly string[]): string => `[${values.map((v) => `"${escapeCedarString(v)}"`).join(", ")}]`;
|
|
85
|
+
|
|
86
|
+
function count(name: string, value: number | undefined, fallback: number): number {
|
|
87
|
+
const n = value ?? fallback;
|
|
88
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`${name} must be a whole number of at least 0, got ${n}`);
|
|
89
|
+
return n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function nonEmpty(name: string, values: readonly string[] | undefined): readonly string[] {
|
|
93
|
+
if (!values || values.length === 0 || values.some((v) => typeof v !== "string" || v === "")) {
|
|
94
|
+
throw new Error(`${name} needs at least one non-empty name`);
|
|
95
|
+
}
|
|
96
|
+
return values;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function principal(appliesTo: GatePackAppliesTo | undefined, fallback: GatePackAppliesTo): PolicyScope {
|
|
100
|
+
return (appliesTo ?? fallback) === "agents" ? { is: AGENT } : {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A forbid on `PassGate` that applies when there is no plan summary, or when `condition` holds. `condition` reads `context.plan`. */
|
|
104
|
+
function forbid(id: string, appliesTo: GatePackAppliesTo | undefined, condition: string, description: string): Declarable {
|
|
105
|
+
return new Policy({
|
|
106
|
+
effect: "forbid",
|
|
107
|
+
principal: principal(appliesTo, "everyone"),
|
|
108
|
+
action: { eq: PASS_GATE },
|
|
109
|
+
// Written as `unless`, so the validator sees `context has plan` guard the
|
|
110
|
+
// read of it (it tracks a `has` through `&&`): the forbid applies unless
|
|
111
|
+
// there is a plan and the condition does not hold.
|
|
112
|
+
unless: [`context has plan && !(${condition})`],
|
|
113
|
+
annotations: { id, description },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CapDeletesOptions extends RuleBase {
|
|
118
|
+
/** The most deletes a plan may carry. Default 0. */
|
|
119
|
+
max?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Forbid a plan that deletes more than `max` resources. Default id `cap-deletes`. */
|
|
123
|
+
export function capDeletes(options: CapDeletesOptions = {}): Declarable {
|
|
124
|
+
const max = count("capDeletes: max", options.max, 0);
|
|
125
|
+
return forbid(
|
|
126
|
+
options.id ?? "cap-deletes", options.appliesTo,
|
|
127
|
+
`context.plan.deletes > ${max}`,
|
|
128
|
+
`A plan may delete at most ${max} resources.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface CapReplacementsOptions extends RuleBase {
|
|
133
|
+
/** The most replacements a plan may carry. Default 0. */
|
|
134
|
+
max?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Forbid a plan that replaces more than `max` resources. Default id `cap-replacements`. */
|
|
138
|
+
export function capReplacements(options: CapReplacementsOptions = {}): Declarable {
|
|
139
|
+
const max = count("capReplacements: max", options.max, 0);
|
|
140
|
+
return forbid(
|
|
141
|
+
options.id ?? "cap-replacements", options.appliesTo,
|
|
142
|
+
`context.plan.replaces > ${max}`,
|
|
143
|
+
`A plan may replace at most ${max} resources.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ProtectStatefulOptions extends RuleBase {
|
|
148
|
+
/** The types that hold state. Default {@link STATEFUL_TYPES}. */
|
|
149
|
+
types?: readonly string[];
|
|
150
|
+
/** Forbid deletes of these types. Default true. */
|
|
151
|
+
deletes?: boolean;
|
|
152
|
+
/** Forbid replacements of these types, which delete the old object too. Default true. */
|
|
153
|
+
replacements?: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Forbid a plan that deletes or replaces a resource of a stateful type: an
|
|
158
|
+
* RDS instance, a DynamoDB table, a bucket. Default id `protect-stateful`.
|
|
159
|
+
*/
|
|
160
|
+
export function protectStateful(options: ProtectStatefulOptions = {}): Declarable {
|
|
161
|
+
const types = set(nonEmpty("protectStateful: types", options.types ?? STATEFUL_TYPES));
|
|
162
|
+
const deletes = options.deletes ?? true;
|
|
163
|
+
const replacements = options.replacements ?? true;
|
|
164
|
+
if (!deletes && !replacements) throw new Error("protectStateful: deletes and replacements are both off, so the rule forbids nothing");
|
|
165
|
+
const conditions = [
|
|
166
|
+
...(deletes ? [`context.plan.deletedTypes.containsAny(${types})`] : []),
|
|
167
|
+
...(replacements ? [`context.plan.replacedTypes.containsAny(${types})`] : []),
|
|
168
|
+
];
|
|
169
|
+
return forbid(
|
|
170
|
+
options.id ?? "protect-stateful", options.appliesTo,
|
|
171
|
+
conditions.length === 1 ? conditions[0] : `(${conditions.join(" || ")})`,
|
|
172
|
+
`A plan may not ${[deletes ? "delete" : "", replacements ? "replace" : ""].filter(Boolean).join(" or ")} a resource that holds state.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface AllowRegionsOptions extends RuleBase {
|
|
177
|
+
regions: readonly string[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Forbid a plan that changes anything outside these regions. Default id `allow-regions`. */
|
|
181
|
+
export function allowRegions(options: AllowRegionsOptions): Declarable {
|
|
182
|
+
const regions = nonEmpty("allowRegions: regions", options.regions);
|
|
183
|
+
return forbid(
|
|
184
|
+
options.id ?? "allow-regions", options.appliesTo,
|
|
185
|
+
`!${set(regions)}.containsAll(context.plan.regions)`,
|
|
186
|
+
`A plan may change resources only in ${regions.join(", ")}.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface RequireCreateTagsOptions extends RuleBase {
|
|
191
|
+
/** Tag (or label) keys every created resource must carry. */
|
|
192
|
+
keys: readonly string[];
|
|
193
|
+
/**
|
|
194
|
+
* Also forbid creates that carry no tags attribute at all. Default false,
|
|
195
|
+
* because many types have no tags (an IAM policy attachment, a route), and
|
|
196
|
+
* a create with tags left unset reads the same.
|
|
197
|
+
*/
|
|
198
|
+
strict?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Forbid a plan whose tagged creates do not all carry `keys`. Reads
|
|
203
|
+
* `tags`, `tags_all` and `labels`. Default id `require-create-tags`.
|
|
204
|
+
*/
|
|
205
|
+
export function requireCreateTags(options: RequireCreateTagsOptions): Declarable {
|
|
206
|
+
const keys = nonEmpty("requireCreateTags: keys", options.keys);
|
|
207
|
+
const missing = `(context.plan.taggedCreates > 0 && !context.plan.createTagKeys.containsAll(${set(keys)}))`;
|
|
208
|
+
return forbid(
|
|
209
|
+
options.id ?? "require-create-tags", options.appliesTo,
|
|
210
|
+
options.strict ? `(${missing} || context.plan.untaggedCreates > 0)` : missing,
|
|
211
|
+
`Every created resource carries the tags ${keys.join(", ")}.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface PermitLowRiskAgentsOptions {
|
|
216
|
+
/** Default id `permit-low-risk-agents`. */
|
|
217
|
+
id?: string;
|
|
218
|
+
/** The most changes (creates, updates, replaces, deletes) a low-risk plan has. Default 10. */
|
|
219
|
+
maxChanges?: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Permit an agent to pass a gate on its own for a plan that deletes and
|
|
224
|
+
* replaces nothing, changes at most `maxChanges` resources, and planned
|
|
225
|
+
* every member in full (no failed member, no hole). In `enforce` mode that
|
|
226
|
+
* permit passes the gate; any forbid in the same set still wins.
|
|
227
|
+
*/
|
|
228
|
+
export function permitLowRiskAgents(options: PermitLowRiskAgentsOptions = {}): Declarable {
|
|
229
|
+
const max = count("permitLowRiskAgents: maxChanges", options.maxChanges, 10);
|
|
230
|
+
return new Policy({
|
|
231
|
+
effect: "permit",
|
|
232
|
+
principal: { is: AGENT },
|
|
233
|
+
action: { eq: PASS_GATE },
|
|
234
|
+
when: [
|
|
235
|
+
"context has plan && " +
|
|
236
|
+
`context.plan.deletes == 0 && context.plan.replaces == 0 && context.plan.changes <= ${max} && ` +
|
|
237
|
+
"context.plan.failed == 0 && context.plan.holes == 0",
|
|
238
|
+
],
|
|
239
|
+
annotations: {
|
|
240
|
+
id: options.id ?? "permit-low-risk-agents",
|
|
241
|
+
description: `An agent may pass a plan that deletes and replaces nothing and changes at most ${max} resources.`,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Permit an agent to pass a plan that only changes tags or labels. Default id `permit-tag-only-agents`. */
|
|
247
|
+
export function permitTagOnlyAgents(options: { id?: string } = {}): Declarable {
|
|
248
|
+
return new Policy({
|
|
249
|
+
effect: "permit",
|
|
250
|
+
principal: { is: AGENT },
|
|
251
|
+
action: { eq: PASS_GATE },
|
|
252
|
+
when: ["context has plan && context.plan.tagOnly && context.plan.failed == 0 && context.plan.holes == 0"],
|
|
253
|
+
annotations: {
|
|
254
|
+
id: options.id ?? "permit-tag-only-agents",
|
|
255
|
+
description: "An agent may pass a plan that only changes tags or labels.",
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface StarterGatePoliciesOptions {
|
|
261
|
+
/** {@link capDeletes}'s max. Default 0. */
|
|
262
|
+
maxDeletes?: number;
|
|
263
|
+
/** {@link capReplacements}'s max. Default: no cap beyond {@link protectStateful}. */
|
|
264
|
+
maxReplacements?: number;
|
|
265
|
+
/** {@link protectStateful}'s types. Default {@link STATEFUL_TYPES}. */
|
|
266
|
+
statefulTypes?: readonly string[];
|
|
267
|
+
/** {@link allowRegions}. Absent, regions are not limited. */
|
|
268
|
+
regions?: readonly string[];
|
|
269
|
+
/** {@link requireCreateTags}. Absent, tags are not required. */
|
|
270
|
+
requiredTags?: readonly string[];
|
|
271
|
+
/** {@link permitLowRiskAgents}'s maxChanges. Default 10. */
|
|
272
|
+
agentMaxChanges?: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The pack as one record for `gatePolicy(name, starterGatePolicies(...))`:
|
|
277
|
+
* stateful types protected, deletes capped, regions and tags when given,
|
|
278
|
+
* and the two agent permits.
|
|
279
|
+
*/
|
|
280
|
+
export function starterGatePolicies(options: StarterGatePoliciesOptions = {}): Record<string, Declarable> {
|
|
281
|
+
return {
|
|
282
|
+
protectStateful: protectStateful(options.statefulTypes ? { types: options.statefulTypes } : {}),
|
|
283
|
+
capDeletes: capDeletes({ max: options.maxDeletes ?? 0 }),
|
|
284
|
+
...(options.maxReplacements !== undefined ? { capReplacements: capReplacements({ max: options.maxReplacements }) } : {}),
|
|
285
|
+
...(options.regions ? { allowRegions: allowRegions({ regions: options.regions }) } : {}),
|
|
286
|
+
...(options.requiredTags ? { requireCreateTags: requireCreateTags({ keys: options.requiredTags }) } : {}),
|
|
287
|
+
permitLowRiskAgents: permitLowRiskAgents(options.agentMaxChanges !== undefined ? { maxChanges: options.agentMaxChanges } : {}),
|
|
288
|
+
permitTagOnlyAgents: permitTagOnlyAgents(),
|
|
289
|
+
};
|
|
290
|
+
}
|
package/src/gate-policy.ts
CHANGED
|
@@ -131,9 +131,9 @@ function cedarValue(value: unknown): CedarValue | undefined {
|
|
|
131
131
|
* The set as a record keyed by each policy's `@id`. Handed the text whole,
|
|
132
132
|
* cedar-wasm names policies by position (`policy0`), and a recorded decision
|
|
133
133
|
* would then name a rule nobody wrote. A policy with no `@id` keeps its
|
|
134
|
-
* positional name.
|
|
134
|
+
* positional name. Exported for `./gate-schema.ts`, so validation names rules the same way.
|
|
135
135
|
*/
|
|
136
|
-
function keyedById(wasm: NonNullable<ReturnType<typeof loadWasm>>, policy: GatePolicyRef): Record<string, string> {
|
|
136
|
+
export function keyedById(wasm: NonNullable<ReturnType<typeof loadWasm>>, policy: GatePolicyRef): Record<string, string> {
|
|
137
137
|
const parts = wasm.policySetTextToParts(policy.text);
|
|
138
138
|
if (parts.type === "failure") {
|
|
139
139
|
throw new Error(`policy "${policy.name}" does not parse: ${parts.errors.map(describeError).join("; ")}`);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cedar schema of a gate's `PassGate` request (chant#3182).
|
|
3
|
+
*
|
|
4
|
+
* {@link GATE_CEDAR_SCHEMA} declares the `Chant` namespace `evaluateGatePolicy`
|
|
5
|
+
* builds requests in (`./gate-policy.ts`), with the plan summary core puts in
|
|
6
|
+
* `context.plan` (`@intentius/chant/op`'s `GatePlanSummary`) as the common type
|
|
7
|
+
* `Chant::PlanSummary`. Two uses:
|
|
8
|
+
*
|
|
9
|
+
* - {@link validateGatePolicy} checks a gate policy set against it, so a rule
|
|
10
|
+
* that reads `context.plan.deleteCount` (a field that does not exist) fails
|
|
11
|
+
* where it is written rather than erroring on every request.
|
|
12
|
+
* - A project that writes gate policies with typed `Policy` scopes puts this
|
|
13
|
+
* text in its own `.cedarschema` (or hands {@link gateCedarSchema}'s output
|
|
14
|
+
* to `new Schema({ text })`), and the generated scope types then name
|
|
15
|
+
* `Chant::Agent` and `Chant::Action::"PassGate"` without a cast.
|
|
16
|
+
*
|
|
17
|
+
* A gate's own `approval.context` keys differ per gate, so the schema declares
|
|
18
|
+
* only `planDigest` and `plan` unless {@link gateCedarSchema} is told the rest.
|
|
19
|
+
* Every context attribute is optional: a gate with no plan, or a plan step
|
|
20
|
+
* that returned no change set, has neither.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { GatePlanSummary, GatePolicyRef } from "@intentius/chant/op";
|
|
24
|
+
import { loadWasm, validatePolicySet, wasmLoadError, type ValidationFinding } from "./lint/post-synth/wasm-helpers";
|
|
25
|
+
import { keyedById } from "./gate-policy";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Each `GatePlanSummary` field as a Cedar type. Typed as a record over the
|
|
29
|
+
* summary's keys, so a field added to the summary in core fails to compile
|
|
30
|
+
* here until the schema says what it is.
|
|
31
|
+
*/
|
|
32
|
+
export const GATE_PLAN_SUMMARY_CEDAR_TYPES: { readonly [K in keyof GatePlanSummary]: string } = {
|
|
33
|
+
members: "Set<String>",
|
|
34
|
+
membersChanged: "Set<String>",
|
|
35
|
+
failedMembers: "Set<String>",
|
|
36
|
+
failed: "Long",
|
|
37
|
+
holes: "Long",
|
|
38
|
+
entries: "Long",
|
|
39
|
+
changes: "Long",
|
|
40
|
+
creates: "Long",
|
|
41
|
+
updates: "Long",
|
|
42
|
+
replaces: "Long",
|
|
43
|
+
deletes: "Long",
|
|
44
|
+
reads: "Long",
|
|
45
|
+
noOps: "Long",
|
|
46
|
+
forgets: "Long",
|
|
47
|
+
types: "Set<String>",
|
|
48
|
+
createdTypes: "Set<String>",
|
|
49
|
+
updatedTypes: "Set<String>",
|
|
50
|
+
replacedTypes: "Set<String>",
|
|
51
|
+
deletedTypes: "Set<String>",
|
|
52
|
+
deleted: "Set<String>",
|
|
53
|
+
replaced: "Set<String>",
|
|
54
|
+
regions: "Set<String>",
|
|
55
|
+
scopes: "Set<String>",
|
|
56
|
+
lexicons: "Set<String>",
|
|
57
|
+
taggedCreates: "Long",
|
|
58
|
+
untaggedCreates: "Long",
|
|
59
|
+
createTagKeys: "Set<String>",
|
|
60
|
+
tagOnly: "Bool",
|
|
61
|
+
truncated: "Bool",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export interface GateCedarSchemaOptions {
|
|
65
|
+
/**
|
|
66
|
+
* The gate's own `approval.context` attributes, as Cedar types
|
|
67
|
+
* (`{ risk: "String", paths: "Set<String>" }`). Each is declared optional,
|
|
68
|
+
* since a step-output reference that resolves to nothing is left out.
|
|
69
|
+
*/
|
|
70
|
+
context?: Readonly<Record<string, string>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
74
|
+
|
|
75
|
+
/** The schema text, with the gate's own context attributes when given. */
|
|
76
|
+
export function gateCedarSchema(options: GateCedarSchemaOptions = {}): string {
|
|
77
|
+
const extra = Object.entries(options.context ?? {});
|
|
78
|
+
for (const [key, type] of extra) {
|
|
79
|
+
if (!IDENT.test(key)) throw new Error(`gateCedarSchema: context attribute "${key}" is not a Cedar identifier`);
|
|
80
|
+
if (key === "plan" || key === "planDigest") throw new Error(`gateCedarSchema: context attribute "${key}" is chant's own`);
|
|
81
|
+
if (type.trim() === "") throw new Error(`gateCedarSchema: context attribute "${key}" has no type`);
|
|
82
|
+
}
|
|
83
|
+
const summary = Object.entries(GATE_PLAN_SUMMARY_CEDAR_TYPES)
|
|
84
|
+
.map(([k, t]) => ` "${k}": ${t},`)
|
|
85
|
+
.join("\n");
|
|
86
|
+
const context = [
|
|
87
|
+
` "planDigest"?: String,`,
|
|
88
|
+
` "plan"?: PlanSummary,`,
|
|
89
|
+
...extra.map(([k, t]) => ` "${k}"?: ${t},`),
|
|
90
|
+
].join("\n");
|
|
91
|
+
return `// The request chant builds for a gate approval (chant#2508, #3182).
|
|
92
|
+
namespace Chant {
|
|
93
|
+
// context.plan: the plan's change-set summary (GatePlanSummary).
|
|
94
|
+
type PlanSummary = {
|
|
95
|
+
${summary}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// A role a human or agent claimed when approving.
|
|
99
|
+
entity Role;
|
|
100
|
+
entity Human in [Role];
|
|
101
|
+
entity Agent in [Role];
|
|
102
|
+
// "<op>/<gate>".
|
|
103
|
+
entity Gate = {
|
|
104
|
+
"op": String,
|
|
105
|
+
"gate": String,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
action PassGate appliesTo {
|
|
109
|
+
principal: [Human, Agent],
|
|
110
|
+
resource: [Gate],
|
|
111
|
+
context: {
|
|
112
|
+
${context}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The schema with no gate-specific context: `planDigest` and `plan` only. */
|
|
120
|
+
export const GATE_CEDAR_SCHEMA = gateCedarSchema();
|
|
121
|
+
|
|
122
|
+
export interface GatePolicyValidation {
|
|
123
|
+
errors: ValidationFinding[];
|
|
124
|
+
warnings: ValidationFinding[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Validate a gate policy set against the gate schema. Throws when the wasm
|
|
129
|
+
* cannot be loaded or validation cannot run at all (a schema that does not
|
|
130
|
+
* parse); findings come back sorted.
|
|
131
|
+
*/
|
|
132
|
+
export function validateGatePolicy(policy: GatePolicyRef, options: GateCedarSchemaOptions = {}): GatePolicyValidation {
|
|
133
|
+
const wasm = loadWasm();
|
|
134
|
+
if (!wasm) throw new Error(`@cedar-policy/cedar-wasm could not be loaded: ${wasmLoadError()}`);
|
|
135
|
+
const outcome = validatePolicySet(wasm, { staticPolicies: keyedById(wasm, policy) }, gateCedarSchema(options));
|
|
136
|
+
if (outcome.failure) throw new Error(`policy "${policy.name}" could not be validated: ${outcome.failure}`);
|
|
137
|
+
return { errors: outcome.errors, warnings: outcome.warnings };
|
|
138
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,21 @@ export {
|
|
|
11
11
|
GATE_HUMAN_TYPE, GATE_AGENT_TYPE, GATE_ROLE_TYPE, GATE_RESOURCE_TYPE, PASS_GATE_ACTION,
|
|
12
12
|
} from "./gate-policy";
|
|
13
13
|
export type { GatePolicies } from "./gate-policy";
|
|
14
|
+
|
|
15
|
+
// Gate policy over the plan (chant#3182): the request's Cedar schema, with the
|
|
16
|
+
// plan summary core puts in `context.plan`, and a starter policy pack over it.
|
|
17
|
+
export {
|
|
18
|
+
GATE_CEDAR_SCHEMA, GATE_PLAN_SUMMARY_CEDAR_TYPES, gateCedarSchema, validateGatePolicy,
|
|
19
|
+
} from "./gate-schema";
|
|
20
|
+
export type { GateCedarSchemaOptions, GatePolicyValidation } from "./gate-schema";
|
|
21
|
+
export {
|
|
22
|
+
STATEFUL_TYPES, allowRegions, capDeletes, capReplacements, permitLowRiskAgents, permitTagOnlyAgents,
|
|
23
|
+
protectStateful, requireCreateTags, starterGatePolicies,
|
|
24
|
+
} from "./gate-policy-pack";
|
|
25
|
+
export type {
|
|
26
|
+
AllowRegionsOptions, CapDeletesOptions, CapReplacementsOptions, GatePackAppliesTo, PermitLowRiskAgentsOptions,
|
|
27
|
+
ProtectStatefulOptions, RequireCreateTagsOptions, StarterGatePoliciesOptions,
|
|
28
|
+
} from "./gate-policy-pack";
|
|
14
29
|
export type { CedarEffect, CedarScope, CedarPolicyProps, CedarPolicyRecord } from "./serializer";
|
|
15
30
|
|
|
16
31
|
// AVP embedding (#1652) — the typed statement an `AWS::VerifiedPermissions::Policy`
|