cedar-mcp-server 1.0.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.
Files changed (215) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.github/workflows/release.yml +42 -0
  4. package/.nvmrc +1 -0
  5. package/CHANGELOG.md +241 -0
  6. package/CONTRIBUTING.md +83 -0
  7. package/LICENSE +182 -0
  8. package/README.md +1635 -0
  9. package/SECURITY.md +37 -0
  10. package/dist/http-server.d.ts +61 -0
  11. package/dist/http-server.d.ts.map +1 -0
  12. package/dist/http-server.js +194 -0
  13. package/dist/http-server.js.map +1 -0
  14. package/dist/index.d.ts +32 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +270 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/parser/policy-ast.d.ts +49 -0
  19. package/dist/parser/policy-ast.d.ts.map +1 -0
  20. package/dist/parser/policy-ast.js +311 -0
  21. package/dist/parser/policy-ast.js.map +1 -0
  22. package/dist/prompts/index.d.ts +38 -0
  23. package/dist/prompts/index.d.ts.map +1 -0
  24. package/dist/prompts/index.js +172 -0
  25. package/dist/prompts/index.js.map +1 -0
  26. package/dist/resources/ref-resolver.d.ts +23 -0
  27. package/dist/resources/ref-resolver.d.ts.map +1 -0
  28. package/dist/resources/ref-resolver.js +128 -0
  29. package/dist/resources/ref-resolver.js.map +1 -0
  30. package/dist/resources/store-manager.d.ts +64 -0
  31. package/dist/resources/store-manager.d.ts.map +1 -0
  32. package/dist/resources/store-manager.js +221 -0
  33. package/dist/resources/store-manager.js.map +1 -0
  34. package/dist/server.d.ts +18 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js +539 -0
  37. package/dist/server.js.map +1 -0
  38. package/dist/tools/advise/avp-rules.d.ts +49 -0
  39. package/dist/tools/advise/avp-rules.d.ts.map +1 -0
  40. package/dist/tools/advise/avp-rules.js +59 -0
  41. package/dist/tools/advise/avp-rules.js.map +1 -0
  42. package/dist/tools/advise/cedar-patterns.d.ts +24 -0
  43. package/dist/tools/advise/cedar-patterns.d.ts.map +1 -0
  44. package/dist/tools/advise/cedar-patterns.js +57 -0
  45. package/dist/tools/advise/cedar-patterns.js.map +1 -0
  46. package/dist/tools/advise/context-builder.d.ts +28 -0
  47. package/dist/tools/advise/context-builder.d.ts.map +1 -0
  48. package/dist/tools/advise/context-builder.js +89 -0
  49. package/dist/tools/advise/context-builder.js.map +1 -0
  50. package/dist/tools/advise/gotchas.d.ts +15 -0
  51. package/dist/tools/advise/gotchas.d.ts.map +1 -0
  52. package/dist/tools/advise/gotchas.js +83 -0
  53. package/dist/tools/advise/gotchas.js.map +1 -0
  54. package/dist/tools/advise.d.ts +96 -0
  55. package/dist/tools/advise.d.ts.map +1 -0
  56. package/dist/tools/advise.js +258 -0
  57. package/dist/tools/advise.js.map +1 -0
  58. package/dist/tools/authorize-batch.d.ts +35 -0
  59. package/dist/tools/authorize-batch.d.ts.map +1 -0
  60. package/dist/tools/authorize-batch.js +262 -0
  61. package/dist/tools/authorize-batch.js.map +1 -0
  62. package/dist/tools/authorize.d.ts +115 -0
  63. package/dist/tools/authorize.d.ts.map +1 -0
  64. package/dist/tools/authorize.js +373 -0
  65. package/dist/tools/authorize.js.map +1 -0
  66. package/dist/tools/check-change.d.ts +19 -0
  67. package/dist/tools/check-change.d.ts.map +1 -0
  68. package/dist/tools/check-change.js +91 -0
  69. package/dist/tools/check-change.js.map +1 -0
  70. package/dist/tools/diff-schema.d.ts +103 -0
  71. package/dist/tools/diff-schema.d.ts.map +1 -0
  72. package/dist/tools/diff-schema.js +379 -0
  73. package/dist/tools/diff-schema.js.map +1 -0
  74. package/dist/tools/diff-stores.d.ts +45 -0
  75. package/dist/tools/diff-stores.d.ts.map +1 -0
  76. package/dist/tools/diff-stores.js +222 -0
  77. package/dist/tools/diff-stores.js.map +1 -0
  78. package/dist/tools/explain.d.ts +80 -0
  79. package/dist/tools/explain.d.ts.map +1 -0
  80. package/dist/tools/explain.js +187 -0
  81. package/dist/tools/explain.js.map +1 -0
  82. package/dist/tools/format.d.ts +11 -0
  83. package/dist/tools/format.d.ts.map +1 -0
  84. package/dist/tools/format.js +20 -0
  85. package/dist/tools/format.js.map +1 -0
  86. package/dist/tools/generate-sample.d.ts +28 -0
  87. package/dist/tools/generate-sample.d.ts.map +1 -0
  88. package/dist/tools/generate-sample.js +568 -0
  89. package/dist/tools/generate-sample.js.map +1 -0
  90. package/dist/tools/link-template.d.ts +17 -0
  91. package/dist/tools/link-template.d.ts.map +1 -0
  92. package/dist/tools/link-template.js +78 -0
  93. package/dist/tools/link-template.js.map +1 -0
  94. package/dist/tools/list-template-links.d.ts +16 -0
  95. package/dist/tools/list-template-links.d.ts.map +1 -0
  96. package/dist/tools/list-template-links.js +22 -0
  97. package/dist/tools/list-template-links.js.map +1 -0
  98. package/dist/tools/list-templates.d.ts +16 -0
  99. package/dist/tools/list-templates.d.ts.map +1 -0
  100. package/dist/tools/list-templates.js +36 -0
  101. package/dist/tools/list-templates.js.map +1 -0
  102. package/dist/tools/translate.d.ts +11 -0
  103. package/dist/tools/translate.d.ts.map +1 -0
  104. package/dist/tools/translate.js +53 -0
  105. package/dist/tools/translate.js.map +1 -0
  106. package/dist/tools/validate-entities.d.ts +19 -0
  107. package/dist/tools/validate-entities.d.ts.map +1 -0
  108. package/dist/tools/validate-entities.js +88 -0
  109. package/dist/tools/validate-entities.js.map +1 -0
  110. package/dist/tools/validate-schema.d.ts +22 -0
  111. package/dist/tools/validate-schema.d.ts.map +1 -0
  112. package/dist/tools/validate-schema.js +89 -0
  113. package/dist/tools/validate-schema.js.map +1 -0
  114. package/dist/tools/validate-template.d.ts +18 -0
  115. package/dist/tools/validate-template.d.ts.map +1 -0
  116. package/dist/tools/validate-template.js +59 -0
  117. package/dist/tools/validate-template.js.map +1 -0
  118. package/dist/tools/validate.d.ts +90 -0
  119. package/dist/tools/validate.d.ts.map +1 -0
  120. package/dist/tools/validate.js +351 -0
  121. package/dist/tools/validate.js.map +1 -0
  122. package/dist/utils/format-detector.d.ts +49 -0
  123. package/dist/utils/format-detector.d.ts.map +1 -0
  124. package/dist/utils/format-detector.js +298 -0
  125. package/dist/utils/format-detector.js.map +1 -0
  126. package/examples/README.md +36 -0
  127. package/examples/abac-multi-tenant/README.md +150 -0
  128. package/examples/abac-multi-tenant/entities/users-and-docs.json +33 -0
  129. package/examples/abac-multi-tenant/policies/member-read-internal.cedar +9 -0
  130. package/examples/abac-multi-tenant/policies/owner-full-access.cedar +9 -0
  131. package/examples/abac-multi-tenant/policies/premium-share-guard.cedar +9 -0
  132. package/examples/abac-multi-tenant/policies/private-doc-guard.cedar +13 -0
  133. package/examples/abac-multi-tenant/run.ts +92 -0
  134. package/examples/abac-multi-tenant/schema.json +60 -0
  135. package/examples/api-gateway-path-routing/README.md +154 -0
  136. package/examples/api-gateway-path-routing/entities/users-and-roles.json +20 -0
  137. package/examples/api-gateway-path-routing/policies/admin-full-access.cedar +6 -0
  138. package/examples/api-gateway-path-routing/policies/developer-projects.cedar +14 -0
  139. package/examples/api-gateway-path-routing/policies/viewer-readonly.cedar +10 -0
  140. package/examples/api-gateway-path-routing/run.ts +108 -0
  141. package/examples/api-gateway-path-routing/schema.json +54 -0
  142. package/examples/rbac-document-management/README.md +167 -0
  143. package/examples/rbac-document-management/entities/users-and-docs.json +43 -0
  144. package/examples/rbac-document-management/policies/admin.cedar +6 -0
  145. package/examples/rbac-document-management/policies/editor.cedar +6 -0
  146. package/examples/rbac-document-management/policies/top-secret-forbid.cedar +13 -0
  147. package/examples/rbac-document-management/policies/viewer.cedar +6 -0
  148. package/examples/rbac-document-management/run.ts +87 -0
  149. package/examples/rbac-document-management/schema.json +57 -0
  150. package/package.json +50 -0
  151. package/src/http-server.ts +239 -0
  152. package/src/index.ts +294 -0
  153. package/src/parser/policy-ast.ts +345 -0
  154. package/src/prompts/README.md +3 -0
  155. package/src/prompts/index.ts +217 -0
  156. package/src/resources/ref-resolver.ts +134 -0
  157. package/src/resources/store-manager.ts +248 -0
  158. package/src/server.ts +711 -0
  159. package/src/tools/advise/avp-rules.ts +70 -0
  160. package/src/tools/advise/cedar-patterns.ts +73 -0
  161. package/src/tools/advise/context-builder.ts +109 -0
  162. package/src/tools/advise/gotchas.ts +92 -0
  163. package/src/tools/advise.ts +366 -0
  164. package/src/tools/authorize-batch.ts +345 -0
  165. package/src/tools/authorize.ts +464 -0
  166. package/src/tools/check-change.ts +119 -0
  167. package/src/tools/diff-schema.ts +510 -0
  168. package/src/tools/diff-stores.ts +298 -0
  169. package/src/tools/explain.ts +278 -0
  170. package/src/tools/format.ts +33 -0
  171. package/src/tools/generate-sample.ts +665 -0
  172. package/src/tools/link-template.ts +109 -0
  173. package/src/tools/list-template-links.ts +41 -0
  174. package/src/tools/list-templates.ts +55 -0
  175. package/src/tools/translate.ts +66 -0
  176. package/src/tools/validate-entities.ts +125 -0
  177. package/src/tools/validate-schema.ts +128 -0
  178. package/src/tools/validate-template.ts +72 -0
  179. package/src/tools/validate.ts +459 -0
  180. package/src/utils/format-detector.ts +356 -0
  181. package/test/fixtures/docmgmt.ts +121 -0
  182. package/test/fixtures/multitenant.ts +163 -0
  183. package/test/index.test.ts +96 -0
  184. package/test/integration/e2e/behavior.test.ts +359 -0
  185. package/test/integration/e2e/edge-cases.test.ts +365 -0
  186. package/test/integration/e2e/failure-modes.test.ts +266 -0
  187. package/test/integration/e2e/protocol.test.ts +252 -0
  188. package/test/integration/http-smoke.test.ts +588 -0
  189. package/test/integration/smoke.test.ts +475 -0
  190. package/test/prompts/prompts.test.ts +173 -0
  191. package/test/property/properties.test.ts +234 -0
  192. package/test/resources/ref-resolver.test.ts +186 -0
  193. package/test/resources/store-manager.test.ts +344 -0
  194. package/test/setup.test.ts +7 -0
  195. package/test/tools/advise/avp-rules.test.ts +76 -0
  196. package/test/tools/advise.test.ts +339 -0
  197. package/test/tools/authorize-batch.test.ts +459 -0
  198. package/test/tools/authorize.test.ts +682 -0
  199. package/test/tools/check-change.test.ts +104 -0
  200. package/test/tools/cross-fixture.test.ts +170 -0
  201. package/test/tools/diff-schema.test.ts +355 -0
  202. package/test/tools/diff-stores.test.ts +291 -0
  203. package/test/tools/explain.test.ts +221 -0
  204. package/test/tools/format.test.ts +33 -0
  205. package/test/tools/generate-sample.test.ts +480 -0
  206. package/test/tools/link-template.test.ts +90 -0
  207. package/test/tools/list-templates.test.ts +151 -0
  208. package/test/tools/translate.test.ts +89 -0
  209. package/test/tools/validate-entities.test.ts +178 -0
  210. package/test/tools/validate-schema.test.ts +86 -0
  211. package/test/tools/validate-template.test.ts +89 -0
  212. package/test/tools/validate.test.ts +331 -0
  213. package/test/utils/format-detector.test.ts +518 -0
  214. package/tsconfig.json +17 -0
  215. package/vitest.config.ts +13 -0
@@ -0,0 +1,665 @@
1
+ import {
2
+ policyToJson,
3
+ isAuthorized,
4
+ schemaToJsonWithResolvedTypes,
5
+ } from "@cedar-policy/cedar-wasm/nodejs";
6
+ import type { PolicyJson, Entities, Schema } from "@cedar-policy/cedar-wasm/nodejs";
7
+ import {
8
+ extractLikeConstraints,
9
+ patternToString,
10
+ type LikeConstraint,
11
+ } from "../parser/policy-ast.js";
12
+
13
+ export interface GenerateSampleInput {
14
+ policy: string;
15
+ schema: string;
16
+ target_decision: "allow" | "deny";
17
+ }
18
+
19
+ export interface EntityPayload {
20
+ uid: { type: string; id: string };
21
+ attrs: Record<string, unknown>;
22
+ parents: Array<{ type: string; id: string }>;
23
+ }
24
+
25
+ export interface GenerateSampleResult {
26
+ principal: string;
27
+ action: string;
28
+ resource: string;
29
+ entities: EntityPayload[];
30
+ explanation: string;
31
+ decision?: "Allow" | "Deny";
32
+ ready_to_test?: boolean;
33
+ error?: string;
34
+ }
35
+
36
+ // ─── Constraint extraction ────────────────────────────────────────────────────
37
+
38
+ interface AttributeConstraint {
39
+ variable: "principal" | "resource" | "context";
40
+ attr: string;
41
+ op: "eq" | "contains" | "has" | "not_has";
42
+ value?: unknown;
43
+ values?: unknown[];
44
+ }
45
+
46
+ function extractConstraints(conditions: PolicyJson["conditions"]): AttributeConstraint[] {
47
+ const constraints: AttributeConstraint[] = [];
48
+ for (const clause of conditions) {
49
+ walkExpr(clause.body, clause.kind, constraints);
50
+ }
51
+ return constraints;
52
+ }
53
+
54
+ function walkExpr(
55
+ expr: unknown,
56
+ clauseKind: "when" | "unless",
57
+ constraints: AttributeConstraint[]
58
+ ): void {
59
+ if (typeof expr !== "object" || expr === null) return;
60
+ const e = expr as Record<string, unknown>;
61
+
62
+ // "like" is handled separately via extractLikeConstraints — skip here
63
+ if ("like" in e) return;
64
+
65
+ if ("&&" in e || "||" in e) {
66
+ const key = "&&" in e ? "&&" : "||";
67
+ const node = e[key] as { left: unknown; right: unknown };
68
+ walkExpr(node.left, clauseKind, constraints);
69
+ walkExpr(node.right, clauseKind, constraints);
70
+ return;
71
+ }
72
+
73
+ // Equality: principal.attr == value or resource.attr == value
74
+ if ("==" in e) {
75
+ const node = e["=="] as { left: unknown; right: unknown };
76
+ const attr = extractAttrAccess(node.left);
77
+ if (attr && clauseKind === "when") {
78
+ const value = extractValue(node.right);
79
+ if (value !== undefined) {
80
+ constraints.push({ variable: attr.variable, attr: attr.attr, op: "eq", value });
81
+ }
82
+ }
83
+ return;
84
+ }
85
+
86
+ // Has (optional attribute guard): resource has attr
87
+ if ("has" in e) {
88
+ const node = e["has"] as { left: unknown; attr: string };
89
+ const varName = extractVar(node.left);
90
+ if (varName && (varName === "principal" || varName === "resource")) {
91
+ if (clauseKind === "when") {
92
+ constraints.push({ variable: varName, attr: node.attr, op: "has" });
93
+ } else {
94
+ constraints.push({ variable: varName, attr: node.attr, op: "not_has" });
95
+ }
96
+ }
97
+ return;
98
+ }
99
+
100
+ // in (set membership in condition body): { "in": { left: attrExpr, right: SetExpr } }
101
+ // e.g. resource.status in ["active", "pending"]
102
+ if ("in" in e && clauseKind === "when") {
103
+ const node = e["in"] as { left: unknown; right: unknown };
104
+ const attr = extractAttrAccess(node.left);
105
+ const right = node.right as Record<string, unknown>;
106
+ if (attr && "Set" in right) {
107
+ const values = (right["Set"] as unknown[]).map(extractValue).filter((v) => v !== undefined);
108
+ if (values.length > 0) {
109
+ constraints.push({ variable: attr.variable, attr: attr.attr, op: "contains", values });
110
+ }
111
+ }
112
+ return;
113
+ }
114
+
115
+ // contains(): { "contains": { "left": setExpr, "right": attrExpr } }
116
+ // e.g. ["active", "pending"].contains(resource.status)
117
+ if ("contains" in e && !Array.isArray(e["contains"]) && typeof e["contains"] === "object") {
118
+ const node = e["contains"] as { left: unknown; right: unknown };
119
+ const setExpr = node.left as Record<string, unknown>;
120
+ const attrExpr = node.right;
121
+ if ("Set" in setExpr && clauseKind === "when") {
122
+ const attr = extractAttrAccess(attrExpr);
123
+ const values = (setExpr["Set"] as unknown[]).map(extractValue).filter((v) => v !== undefined);
124
+ if (attr && values.length > 0) {
125
+ constraints.push({ variable: attr.variable, attr: attr.attr, op: "contains", values });
126
+ }
127
+ }
128
+ return;
129
+ }
130
+ }
131
+
132
+ function extractAttrAccess(
133
+ expr: unknown
134
+ ): { variable: "principal" | "resource" | "context"; attr: string } | null {
135
+ if (typeof expr !== "object" || expr === null) return null;
136
+ const e = expr as Record<string, unknown>;
137
+ if ("." in e) {
138
+ const node = e["."] as { left: unknown; attr: string };
139
+ const varName = extractVar(node.left);
140
+ if (varName === "principal" || varName === "resource" || varName === "context") {
141
+ return { variable: varName, attr: node.attr };
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function extractVar(expr: unknown): string | null {
148
+ if (typeof expr === "object" && expr !== null && "Var" in (expr as Record<string, unknown>)) {
149
+ return (expr as Record<string, string>)["Var"] ?? null;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ function extractValue(expr: unknown): unknown {
155
+ if (typeof expr !== "object" || expr === null) return undefined;
156
+ const e = expr as Record<string, unknown>;
157
+ if ("Value" in e) {
158
+ const v = e["Value"];
159
+ if (v !== null && typeof v === "object" && "__entity" in (v as Record<string, unknown>)) {
160
+ return undefined; // entity reference — skip for simple attr matching
161
+ }
162
+ return v;
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ // ─── Scope extraction ─────────────────────────────────────────────────────────
168
+
169
+ interface ScopeInfo {
170
+ principalType: string;
171
+ principalRoleType?: string;
172
+ principalRoleId?: string;
173
+ actionType: string;
174
+ actionId?: string;
175
+ resourceType: string;
176
+ }
177
+
178
+ /**
179
+ * Returns required attributes for an entity type from the (resolved) schema JSON.
180
+ * Only includes `required: true` attributes — optional ones are omitted unless
181
+ * the policy conditions explicitly reference them.
182
+ * Returns a map of attrName → default value based on Cedar type.
183
+ */
184
+ function requiredAttrsFromSchema(
185
+ schemaJson: unknown,
186
+ namespace: string,
187
+ entityTypeName: string
188
+ ): Record<string, unknown> {
189
+ try {
190
+ const ns = (schemaJson as Record<string, unknown>)?.[namespace] as Record<string, unknown>;
191
+ const entityTypes = ns?.["entityTypes"] as Record<string, unknown>;
192
+ // entityTypeName may be fully-qualified "Ns::Type" or just "Type"
193
+ const simpleTypeName = entityTypeName.includes("::")
194
+ ? entityTypeName.split("::").pop()!
195
+ : entityTypeName;
196
+ const entityDef = entityTypes?.[simpleTypeName] as Record<string, unknown>;
197
+ const shape = entityDef?.["shape"] as Record<string, unknown>;
198
+ const attributes = shape?.["attributes"] as Record<string, Record<string, unknown>>;
199
+ if (!attributes) return {};
200
+
201
+ const defaults: Record<string, unknown> = {};
202
+ for (const [attrName, attrDef] of Object.entries(attributes)) {
203
+ // Cedar JSON-schema default for `required` is true (per the official
204
+ // spec); only attributes with an explicit `required: false` are optional.
205
+ // The old `!== true` check skipped attributes when the JSON omitted the
206
+ // flag entirely, which is the shape `schemaToJsonWithResolvedTypes`
207
+ // emits for cedarschema-text input like `entity User { name: String }`.
208
+ // Empty-attrs entities then failed `validateRequest` once the schema
209
+ // was supplied to the internal verification call (kickoff-14 14d audit
210
+ // Finding F3 follow-on).
211
+ if (attrDef["required"] === false) continue;
212
+ const typeName = (attrDef["type"] as string | undefined)?.toLowerCase() ?? "";
213
+ if (typeName === "string") defaults[attrName] = "";
214
+ else if (typeName === "long") defaults[attrName] = 0;
215
+ else if (typeName === "boolean") defaults[attrName] = false;
216
+ // Records, Sets, extension types: leave to the caller to set meaningfully
217
+ }
218
+ return defaults;
219
+ } catch {
220
+ return {};
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Qualify a bare entity-type name with the schema's namespace. If the name
226
+ * already carries a `::` separator (which `schemaToJsonWithResolvedTypes`
227
+ * emits for entries declared inside `namespace X { ... }` cedarschema text),
228
+ * return it verbatim — re-prefixing produces `MyApp::MyApp::User` style
229
+ * double-namespace artifacts (kickoff-14 14b).
230
+ */
231
+ function qualifyEntityType(typeName: string, namespace: string): string {
232
+ if (typeName.includes("::")) return typeName;
233
+ return namespace ? `${namespace}::${typeName}` : typeName;
234
+ }
235
+
236
+ function entityTypesFromSchema(
237
+ schemaJson: unknown,
238
+ namespace: string,
239
+ actionId: string | undefined
240
+ ): { principalType: string; resourceType: string } {
241
+ try {
242
+ const ns = (schemaJson as Record<string, unknown>)?.[namespace] as Record<string, unknown>;
243
+ const actions = ns?.["actions"] as Record<string, unknown>;
244
+ const actionKey = actionId ? actions?.[actionId] : Object.values(actions ?? {})[0];
245
+ const appliesTo = (actionKey as Record<string, unknown>)?.["appliesTo"] as Record<string, unknown>;
246
+ const principalTypes = appliesTo?.["principalTypes"] as string[] | undefined;
247
+ const resourceTypes = appliesTo?.["resourceTypes"] as string[] | undefined;
248
+ return {
249
+ principalType: principalTypes?.[0] ? qualifyEntityType(principalTypes[0], namespace) : qualifyEntityType("User", namespace),
250
+ resourceType: resourceTypes?.[0] ? qualifyEntityType(resourceTypes[0], namespace) : qualifyEntityType("Resource", namespace),
251
+ };
252
+ } catch {
253
+ return { principalType: qualifyEntityType("User", namespace), resourceType: qualifyEntityType("Resource", namespace) };
254
+ }
255
+ }
256
+
257
+ function extractScope(json: PolicyJson, schemaNamespace: string, schemaJson?: unknown): ScopeInfo {
258
+ // qualifyEntityType handles the empty-namespace case (Cedar's "" namespace
259
+ // for namespaceless schemas) by returning bare "Action" instead of "::Action".
260
+ const actionType = qualifyEntityType("Action", schemaNamespace);
261
+
262
+ let actionId: string | undefined;
263
+ let principalRoleType: string | undefined;
264
+ let principalRoleId: string | undefined;
265
+ // Direct principal/resource type pins (from `principal == Type::"id"` /
266
+ // `resource == Type::"id"`). When present, these override the
267
+ // schema-derived defaults so the generated request matches what the
268
+ // policy explicitly scoped to.
269
+ let pinnedPrincipalType: string | undefined;
270
+ let pinnedResourceType: string | undefined;
271
+
272
+ // Extract action from scope
273
+ if (json.action.op === "==") {
274
+ const e = "entity" in json.action ? (json.action as Record<string, unknown>)["entity"] as { type: string; id: string } : null;
275
+ if (e) actionId = e.id;
276
+ } else if (json.action.op === "in") {
277
+ const entities = "entities" in json.action
278
+ ? (json.action as Record<string, unknown>)["entities"] as Array<{ type: string; id: string }>
279
+ : "entity" in json.action
280
+ ? [(json.action as Record<string, unknown>)["entity"] as { type: string; id: string }]
281
+ : [];
282
+ if (entities[0]) actionId = entities[0].id;
283
+ }
284
+
285
+ // Extract principal from scope.
286
+ //
287
+ // `op === "in"` is the role-membership pattern: principal in Role::"X".
288
+ // We record principalRoleType + principalRoleId so the entity builder
289
+ // can attach the role as a parent.
290
+ //
291
+ // `op === "=="` is the direct pin: principal == User::"alice".
292
+ // The principal type itself is information the generator needs (it
293
+ // tells us which entity type to instantiate). Without this, the
294
+ // generator fell back to schema-derived defaults that didn't always
295
+ // match the policy's principal pin — caught by a regression test on
296
+ // defaultActionIdFromSchema when the schema's first action's
297
+ // appliesTo.principalTypes disagreed with the policy's pinned type.
298
+ if (json.principal.op === "in") {
299
+ const e = "entity" in json.principal ? (json.principal as Record<string, unknown>)["entity"] as { type: string; id: string } : null;
300
+ if (e) {
301
+ principalRoleType = e.type;
302
+ principalRoleId = e.id;
303
+ }
304
+ } else if (json.principal.op === "==") {
305
+ const e = "entity" in json.principal ? (json.principal as Record<string, unknown>)["entity"] as { type: string; id: string } : null;
306
+ if (e) {
307
+ pinnedPrincipalType = e.type;
308
+ }
309
+ }
310
+
311
+ // Same handling for resource direct-pin.
312
+ if (json.resource.op === "==") {
313
+ const e = "entity" in json.resource ? (json.resource as Record<string, unknown>)["entity"] as { type: string; id: string } : null;
314
+ if (e) {
315
+ pinnedResourceType = e.type;
316
+ }
317
+ }
318
+
319
+ const derived = entityTypesFromSchema(schemaJson, schemaNamespace, actionId);
320
+ const principalType = pinnedPrincipalType ?? derived.principalType;
321
+ const resourceType = pinnedResourceType ?? derived.resourceType;
322
+
323
+ return {
324
+ principalType,
325
+ principalRoleType,
326
+ principalRoleId,
327
+ actionType,
328
+ actionId,
329
+ resourceType,
330
+ };
331
+ }
332
+
333
+ // ─── Entity building ──────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * Pick a default action id when the policy scope doesn't specify one.
337
+ *
338
+ * Original fallback was a hardcoded `"READ"` (uppercase) which mismatched
339
+ * schemas declaring lowercase action keys (e.g. `actions: { read: { ... } }`).
340
+ * Cedar's request validator then rejected the request because `Action::"READ"`
341
+ * isn't declared, causing a default-deny that contradicted the generator's
342
+ * own `decision: "Allow"` self-report. Caught by e2e behavior test B3.
343
+ *
344
+ * The fix evolved through two iterations:
345
+ *
346
+ * v1: return Object.keys(actions)[0] — picked the first declared action.
347
+ * Broke when the schema's first action had `appliesTo.principalTypes`
348
+ * that didn't include the scope's principal type. Example:
349
+ * { adminOnly: { appliesTo: ["Admin"] }, read: { appliesTo: ["User"] } }
350
+ * with a policy targeting `User` would pick `adminOnly`, then schema
351
+ * validation rejects because the principal type doesn't apply.
352
+ *
353
+ * v2 (this version): find an action whose `appliesTo.principalTypes` includes
354
+ * the scope's bare principal type (e.g. "User" extracted from
355
+ * "DocMgmt::User"). Falls back to the first action only if no match.
356
+ * Final fallback is lowercase "read" when no schema is supplied at all.
357
+ */
358
+ function defaultActionIdFromSchema(
359
+ schemaJson: unknown,
360
+ namespace: string,
361
+ principalType?: string // full namespaced form like "DocMgmt::User"
362
+ ): string {
363
+ try {
364
+ const ns = (schemaJson as Record<string, unknown>)?.[namespace] as Record<string, unknown> | undefined;
365
+ const actions = ns?.["actions"] as Record<string, Record<string, unknown>> | undefined;
366
+ if (!actions) return "read";
367
+
368
+ const keys = Object.keys(actions);
369
+ if (keys.length === 0) return "read";
370
+
371
+ // Extract bare principal type name ("User" from "DocMgmt::User") for matching
372
+ // against the schema's appliesTo.principalTypes (which are stored unprefixed).
373
+ const barePrincipalType = principalType
374
+ ? principalType.split("::").pop()
375
+ : undefined;
376
+
377
+ if (barePrincipalType) {
378
+ for (const key of keys) {
379
+ const appliesTo = actions[key]?.["appliesTo"] as Record<string, unknown> | undefined;
380
+ const principalTypes = appliesTo?.["principalTypes"] as string[] | undefined;
381
+ if (principalTypes && principalTypes.includes(barePrincipalType)) {
382
+ return key;
383
+ }
384
+ }
385
+ }
386
+
387
+ // No action has appliesTo matching the scope's principal type, OR no principal
388
+ // type was passed. Fall back to first declared action — better than the old
389
+ // hardcoded "READ" because at least it's a real declared action.
390
+ return keys[0]!;
391
+ } catch { /* fall through */ }
392
+ return "read";
393
+ }
394
+
395
+ function buildEntities(
396
+ scope: ScopeInfo,
397
+ constraints: AttributeConstraint[],
398
+ targetDecision: "allow" | "deny",
399
+ schemaNamespace: string,
400
+ likeConstraints: LikeConstraint[] = [],
401
+ schemaJson?: unknown
402
+ ): { entities: EntityPayload[]; principalId: string; actionId: string; resourceId: string } {
403
+ const principalId = "sample-principal";
404
+ const resourceId = "sample-resource";
405
+ const actionId = scope.actionId ?? defaultActionIdFromSchema(schemaJson, schemaNamespace, scope.principalType);
406
+
407
+ // Seed required attributes from schema so validateRequest: true doesn't fail on missing fields.
408
+ // Condition-derived values (eq, has, contains, like) overwrite these defaults below.
409
+ const principalAttrs: Record<string, unknown> = schemaJson
410
+ ? requiredAttrsFromSchema(schemaJson, schemaNamespace, scope.principalType)
411
+ : {};
412
+ const resourceAttrs: Record<string, unknown> = schemaJson
413
+ ? requiredAttrsFromSchema(schemaJson, schemaNamespace, scope.resourceType)
414
+ : {};
415
+
416
+ // For deny, prefer violating a "has" constraint first, then "contains"/"eq".
417
+ // Omitting an optional attribute is the clearest deny signal.
418
+ let violatedConstraint: AttributeConstraint | null = null;
419
+ if (targetDecision === "deny") {
420
+ violatedConstraint =
421
+ constraints.find((c) => c.op === "has" && c.variable === "resource") ??
422
+ constraints.find((c) => c.op === "has" && c.variable === "principal") ??
423
+ constraints.find((c) => c.op === "contains") ??
424
+ constraints.find((c) => c.op === "eq") ??
425
+ null;
426
+ }
427
+
428
+ for (const c of constraints) {
429
+ const shouldSatisfy = targetDecision === "allow" || c !== violatedConstraint;
430
+
431
+ if (c.variable === "principal") {
432
+ if (c.op === "eq" && shouldSatisfy) principalAttrs[c.attr] = c.value;
433
+ if (c.op === "eq" && !shouldSatisfy) principalAttrs[c.attr] = `__deny_${c.attr}`;
434
+ if (c.op === "contains" && shouldSatisfy) principalAttrs[c.attr] = c.values?.[0];
435
+ if (c.op === "contains" && !shouldSatisfy) principalAttrs[c.attr] = `__deny_not_in_set`;
436
+ }
437
+
438
+ if (c.variable === "resource") {
439
+ // If we're denying by omitting this attr (has-violated), skip its eq constraint too
440
+ const attrOmittedByDeny =
441
+ violatedConstraint?.op === "has" &&
442
+ violatedConstraint.variable === "resource" &&
443
+ violatedConstraint.attr === c.attr;
444
+
445
+ if (c.op === "eq" && shouldSatisfy && !attrOmittedByDeny) resourceAttrs[c.attr] = c.value;
446
+ if (c.op === "eq" && !shouldSatisfy) resourceAttrs[c.attr] = `__deny_${c.attr}`;
447
+ // contains/in: pick first value from set for allow, sentinel not in set for deny
448
+ if (c.op === "contains" && shouldSatisfy) resourceAttrs[c.attr] = c.values?.[0];
449
+ if (c.op === "contains" && !shouldSatisfy) resourceAttrs[c.attr] = `__deny_not_in_set`;
450
+ if (c.op === "has" && shouldSatisfy) {
451
+ // Include the optional attr — set to a neutral value if no eq constraint follows
452
+ const eqForAttr = constraints.find(
453
+ (x) => x.op === "eq" && x.variable === "resource" && x.attr === c.attr
454
+ );
455
+ if (!eqForAttr) resourceAttrs[c.attr] = "present";
456
+ }
457
+ if (c.op === "has" && !shouldSatisfy) {
458
+ // Omit the optional attribute — deny by not having it
459
+ delete resourceAttrs[c.attr];
460
+ }
461
+ if (c.op === "not_has") {
462
+ // This is from an "unless" clause — omit the attr to satisfy the denial condition
463
+ delete resourceAttrs[c.attr];
464
+ }
465
+ }
466
+ }
467
+
468
+ // Apply like-based attribute generation.
469
+ // For deny: negative like (depth-limit) takes priority over eq-violation for the same attribute —
470
+ // it produces a more educational value (e.g. "/api/v1/projects/x/x" beats "__deny_path").
471
+ const attrsWithNegativeLike = new Set(
472
+ likeConstraints
473
+ .filter((lc) => lc.negated && targetDecision === "deny")
474
+ .map((lc) => `${lc.variable}.${lc.attr}`)
475
+ );
476
+
477
+ for (const lc of likeConstraints) {
478
+ const target = lc.variable === "resource" ? resourceAttrs : principalAttrs;
479
+ const key = `${lc.variable}.${lc.attr}`;
480
+ // Allow: skip if already set by an eq constraint (== covers the allow case via ||)
481
+ // Deny: skip only if there's no negative like for this attr (eq-violation is the fallback)
482
+ if (target[lc.attr] !== undefined && !(targetDecision === "deny" && attrsWithNegativeLike.has(key))) continue;
483
+
484
+ if (targetDecision === "allow" && !lc.negated) {
485
+ target[lc.attr] = patternToString(lc.pattern, "x");
486
+ } else if (targetDecision === "deny" && lc.negated) {
487
+ // Satisfying the negative pattern makes !like false → deny
488
+ target[lc.attr] = patternToString(lc.pattern, "x");
489
+ } else if (targetDecision === "deny" && !lc.negated) {
490
+ // No negative pattern to exploit — use a non-matching prefix
491
+ // Validation loop will catch if this doesn't produce a deny
492
+ if (target[lc.attr] === undefined) target[lc.attr] = "/deny/path";
493
+ }
494
+ }
495
+
496
+ const principalEntity: EntityPayload = {
497
+ uid: { type: scope.principalType, id: principalId },
498
+ attrs: principalAttrs,
499
+ parents: scope.principalRoleType && scope.principalRoleId
500
+ ? [{ type: scope.principalRoleType, id: scope.principalRoleId }]
501
+ : [],
502
+ };
503
+
504
+ const resourceEntity: EntityPayload = {
505
+ uid: { type: scope.resourceType, id: resourceId },
506
+ attrs: resourceAttrs,
507
+ parents: [],
508
+ };
509
+
510
+ const entities: EntityPayload[] = [principalEntity, resourceEntity];
511
+
512
+ // Add role entity if needed
513
+ if (scope.principalRoleType && scope.principalRoleId) {
514
+ entities.push({
515
+ uid: { type: scope.principalRoleType, id: scope.principalRoleId },
516
+ attrs: {},
517
+ parents: [],
518
+ });
519
+ }
520
+
521
+ return {
522
+ entities,
523
+ principalId,
524
+ actionId,
525
+ resourceId,
526
+ };
527
+ }
528
+
529
+ // ─── Handler ──────────────────────────────────────────────────────────────────
530
+
531
+ export async function handleGenerateSample(input: GenerateSampleInput): Promise<GenerateSampleResult> {
532
+ // Parse policy
533
+ const policyResult = policyToJson(input.policy);
534
+ if (policyResult.type === "failure") {
535
+ return { principal: "", action: "", resource: "", entities: [], explanation: "", error: policyResult.errors.map((e) => e.message).join("; ") };
536
+ }
537
+ const json = policyResult.json;
538
+
539
+ // Extract namespace and schema JSON for entity type lookup.
540
+ // schemaToJsonWithResolvedTypes only accepts Cedar text — for JSON schemas, parse directly.
541
+ //
542
+ // Cedar's "namespaceless" schema uses an empty-string namespace key:
543
+ // `{"": {entityTypes: {...}}}`. Object.keys returns `[""]`, and treating
544
+ // that as truthy via `if (ns)` previously fell through to the hardcoded
545
+ // "MyApp" default, hallucinating a namespace the schema didn't declare.
546
+ // `if (ns !== undefined)` keeps the empty string as a legitimate namespace
547
+ // that downstream `qualifyEntityType` rewrites as no prefix at all
548
+ // (kickoff-14 14d audit Finding F2).
549
+ let schemaNamespace = "MyApp";
550
+ let schemaJson: unknown = undefined;
551
+ try {
552
+ const parsed = JSON.parse(input.schema);
553
+ const ns = Object.keys(parsed)[0];
554
+ if (ns !== undefined) { schemaNamespace = ns; schemaJson = parsed; }
555
+ } catch {
556
+ // Not JSON — try Cedar text schema
557
+ try {
558
+ const schemaResult = schemaToJsonWithResolvedTypes(input.schema);
559
+ if (schemaResult.type === "success") {
560
+ const ns = Object.keys(schemaResult.json)[0];
561
+ if (ns !== undefined) { schemaNamespace = ns; schemaJson = schemaResult.json; }
562
+ }
563
+ } catch {
564
+ // Non-fatal — proceed with default namespace
565
+ }
566
+ }
567
+
568
+ // Extract equality/has constraints and like constraints separately
569
+ const constraints: AttributeConstraint[] = extractConstraints(json.conditions);
570
+ const likeConstraints: LikeConstraint[] = extractLikeConstraints(json.conditions);
571
+
572
+ const scope = extractScope(json, schemaNamespace, schemaJson);
573
+
574
+ // Build entities, passing like constraints for path-matching generation
575
+ const { entities, principalId, actionId, resourceId } = buildEntities(
576
+ scope, constraints, input.target_decision, schemaNamespace, likeConstraints, schemaJson
577
+ );
578
+
579
+ const principalRef = `${scope.principalType}::"${principalId}"`;
580
+ const actionRef = `${scope.actionType}::"${actionId}"`;
581
+ const resourceRef = `${scope.resourceType}::"${resourceId}"`;
582
+
583
+ // Validate the generated payload with isAuthorized. Pass the user's schema
584
+ // with `validateRequest: true` so a generator-fabricated entity type that
585
+ // doesn't exist in the schema (e.g. when the schema has no namespace and
586
+ // an earlier code path leaked a default like `MyApp::Resource`) flips
587
+ // `ready_to_test` to false instead of falsely claiming the payload is
588
+ // ready (kickoff-14 14d audit Finding F3).
589
+ let verifySchema: Schema | undefined;
590
+ try {
591
+ verifySchema = JSON.parse(input.schema) as Schema;
592
+ } catch {
593
+ verifySchema = input.schema as Schema;
594
+ }
595
+ const authResult = isAuthorized({
596
+ principal: { type: scope.principalType, id: principalId },
597
+ action: { type: scope.actionType, id: actionId },
598
+ resource: { type: scope.resourceType, id: resourceId },
599
+ context: {},
600
+ policies: { staticPolicies: input.policy },
601
+ entities: entities as Entities,
602
+ schema: verifySchema,
603
+ validateRequest: true,
604
+ });
605
+
606
+ if (authResult.type === "failure") {
607
+ return {
608
+ principal: principalRef,
609
+ action: actionRef,
610
+ resource: resourceRef,
611
+ entities,
612
+ explanation: "Authorization check failed during validation.",
613
+ error: authResult.errors.map((e) => e.message).join("; "),
614
+ };
615
+ }
616
+
617
+ let actualDecision: "Allow" | "Deny" = authResult.response.decision === "allow" ? "Allow" : "Deny";
618
+ const targetLabel = input.target_decision === "allow" ? "Allow" : "Deny";
619
+
620
+ // Retry once with fallback if initial generation missed the target.
621
+ // For like-deny with no negative pattern, try the opposite wildcard count.
622
+ if (actualDecision !== targetLabel && likeConstraints.length > 0) {
623
+ const fallbackAttrs = { ...entities.find(e => e.uid.type === scope.resourceType)?.attrs ?? {} };
624
+ for (const lc of likeConstraints.filter(l => !l.negated && l.variable === "resource")) {
625
+ // For deny fallback: try a completely off-prefix path
626
+ if (input.target_decision === "deny") fallbackAttrs[lc.attr] = "/deny/path/mismatch";
627
+ // For allow fallback: try two wildcard segments (sometimes needed for complex patterns)
628
+ if (input.target_decision === "allow") fallbackAttrs[lc.attr] = patternToString(lc.pattern, "sample");
629
+ }
630
+ const retryEntities = entities.map(e =>
631
+ e.uid.type === scope.resourceType ? { ...e, attrs: fallbackAttrs } : e
632
+ );
633
+ const retryResult = isAuthorized({
634
+ principal: { type: scope.principalType, id: principalId },
635
+ action: { type: scope.actionType, id: actionId },
636
+ resource: { type: scope.resourceType, id: resourceId },
637
+ context: {},
638
+ policies: { staticPolicies: input.policy },
639
+ entities: retryEntities as Entities,
640
+ schema: verifySchema,
641
+ validateRequest: true,
642
+ });
643
+ if (retryResult.type === "success") {
644
+ const retryDecision = retryResult.response.decision === "allow" ? "Allow" : "Deny";
645
+ if (retryDecision === targetLabel) {
646
+ actualDecision = retryDecision;
647
+ entities.splice(0, entities.length, ...retryEntities);
648
+ }
649
+ }
650
+ }
651
+
652
+ const explanation = actualDecision === targetLabel
653
+ ? `This request will be ${actualDecision.toUpperCase()} as expected.`
654
+ : `Generated payload produced ${actualDecision} instead of expected ${targetLabel}. The policy conditions may be more complex than automated extraction supports.`;
655
+
656
+ return {
657
+ principal: principalRef,
658
+ action: actionRef,
659
+ resource: resourceRef,
660
+ entities,
661
+ explanation,
662
+ decision: actualDecision,
663
+ ready_to_test: actualDecision === targetLabel,
664
+ };
665
+ }