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,464 @@
1
+ import { isAuthorized, policySetTextToParts, policyToJson } from "@cedar-policy/cedar-wasm/nodejs";
2
+ import type {
3
+ AuthorizationCall,
4
+ CedarValueJson,
5
+ Entities,
6
+ Policy,
7
+ PolicyId,
8
+ Schema,
9
+ } from "@cedar-policy/cedar-wasm/nodejs";
10
+ import {
11
+ detectFormat,
12
+ normalizeEntities,
13
+ normalizePrincipalRef,
14
+ } from "../utils/format-detector.js";
15
+ import type { FormatDetectionResult } from "../utils/format-detector.js";
16
+ import { storeManager } from "../resources/store-manager.js";
17
+
18
+ export interface AuthorizeInput {
19
+ /** Concatenated Cedar policy text. Mutually exclusive with policiesMap. */
20
+ policies?: string;
21
+ /**
22
+ * Map of policy id -> policy text. Each key becomes the WASM policy_id and
23
+ * surfaces in determining_policies (overridden by an `@id` annotation when
24
+ * present). Use this when the caller knows the source filename per policy.
25
+ */
26
+ policiesMap?: Record<string, string>;
27
+ principal: string | Record<string, unknown>;
28
+ action: string | Record<string, unknown>;
29
+ resource: string | Record<string, unknown>;
30
+ entities: string;
31
+ schema?: string;
32
+ context?: string;
33
+ /**
34
+ * Optional store name to disambiguate workspace auto-discovery (10d) at the
35
+ * MCP layer. handleAuthorize itself does not consult the StoreManager; the
36
+ * server.ts handler resolves this and supplies inputs before calling in.
37
+ */
38
+ store?: string;
39
+ }
40
+
41
+ export type AuthorizeDecisionReason =
42
+ | "permit_policy_fired"
43
+ | "forbid_policy_fired"
44
+ | "default_deny_no_permit_matched"
45
+ | "evaluation_error";
46
+
47
+ export interface AuthorizeResult {
48
+ decision: "Allow" | "Deny";
49
+ determining_policies: string[];
50
+ errors: string[];
51
+ decision_reason?: AuthorizeDecisionReason;
52
+ format_detected?: string;
53
+ format_note?: string;
54
+ error?: string;
55
+ /**
56
+ * 10d workspace auto-discovery: populated by the server.ts MCP handler when
57
+ * one or more inputs were resolved from a loaded MCP root rather than from
58
+ * inline params. Each subfield names the store that satisfied the missing
59
+ * input. Surfaces so the caller can trace which store the decision used.
60
+ */
61
+ auto_discovered?: {
62
+ policies_from?: string;
63
+ schema_from?: string;
64
+ entities_from?: string;
65
+ };
66
+ }
67
+
68
+ const DENY_RESULT = (error: string, detection?: FormatDetectionResult): AuthorizeResult => ({
69
+ decision: "Deny",
70
+ determining_policies: [],
71
+ errors: [],
72
+ ...(detection ? { format_detected: detection.format, format_note: detection.note } : {}),
73
+ error,
74
+ });
75
+
76
+ /**
77
+ * Extract the @id annotation from a single Cedar policy text, if present.
78
+ * Returns undefined when the policy fails to parse OR when no @id is set.
79
+ */
80
+ function readIdAnnotation(policyText: string): string | undefined {
81
+ const parsed = policyToJson(policyText);
82
+ if (parsed.type !== "success") return undefined;
83
+ const id = parsed.json.annotations?.id;
84
+ return typeof id === "string" && id.length > 0 ? id : undefined;
85
+ }
86
+
87
+ /**
88
+ * Build a Record<policyId, Policy> from caller input.
89
+ *
90
+ * Resolution order for each policy's id:
91
+ * 1. The @id("name") annotation on the policy (highest priority)
92
+ * 2. The caller-provided basename (key in policiesMap)
93
+ * 3. positional "policy<index>" fallback
94
+ *
95
+ * For a flat policies string, we split it via policySetTextToParts so each
96
+ * policy gets its own id rather than collapsing into a single anonymous blob.
97
+ *
98
+ * Exported so cedar_authorize_batch can apply the same H1 resolution and
99
+ * surface the same stable IDs as the single-request handler (kickoff-14 14a).
100
+ */
101
+ export function buildStaticPolicies(
102
+ input: Pick<AuthorizeInput, "policies" | "policiesMap">
103
+ ): { record: Record<PolicyId, Policy> } | { error: string } {
104
+ const record: Record<PolicyId, Policy> = {};
105
+ const usedIds = new Set<string>();
106
+
107
+ const assignId = (preferredId: string, fallbackBase: string): string => {
108
+ let id = preferredId;
109
+ if (usedIds.has(id)) {
110
+ // Disambiguate duplicates so the WASM call does not collide.
111
+ let suffix = 2;
112
+ while (usedIds.has(`${fallbackBase}-${suffix}`)) suffix++;
113
+ id = `${fallbackBase}-${suffix}`;
114
+ }
115
+ usedIds.add(id);
116
+ return id;
117
+ };
118
+
119
+ if (input.policiesMap) {
120
+ for (const [basename, text] of Object.entries(input.policiesMap)) {
121
+ const annotation = readIdAnnotation(text);
122
+ const preferred = annotation ?? basename;
123
+ const id = assignId(preferred, basename);
124
+ record[id] = text;
125
+ }
126
+ return { record };
127
+ }
128
+
129
+ const text = input.policies ?? "";
130
+ const parts = policySetTextToParts(text);
131
+ if (parts.type === "failure") {
132
+ // Fall through with a single positional entry; downstream isAuthorized
133
+ // will surface the parse error in the standard errors[] channel.
134
+ record["policy0"] = text;
135
+ return { record };
136
+ }
137
+
138
+ parts.policies.forEach((policyText: string, idx: number) => {
139
+ const annotation = readIdAnnotation(policyText);
140
+ const fallback = `policy${idx}`;
141
+ const preferred = annotation ?? fallback;
142
+ const id = assignId(preferred, fallback);
143
+ record[id] = policyText;
144
+ });
145
+
146
+ return { record };
147
+ }
148
+
149
+ /**
150
+ * Classify the authorization outcome into one of four reason codes.
151
+ * See AuthorizeDecisionReason for the contract.
152
+ */
153
+ function classifyDecisionReason(
154
+ decision: "Allow" | "Deny",
155
+ determining: string[],
156
+ errors: string[],
157
+ staticPolicies: Record<PolicyId, Policy>
158
+ ): AuthorizeDecisionReason {
159
+ if (errors.length > 0) return "evaluation_error";
160
+ if (decision === "Allow") return "permit_policy_fired";
161
+ // Deny path.
162
+ if (determining.length === 0) return "default_deny_no_permit_matched";
163
+ // At least one determining policy fired on a Deny -> a forbid policy.
164
+ // Verify defensively by checking the policy's effect; if any determining
165
+ // entry parses as forbid, classify as forbid_policy_fired.
166
+ for (const id of determining) {
167
+ const text = staticPolicies[id];
168
+ if (typeof text !== "string") continue;
169
+ const parsed = policyToJson(text);
170
+ if (parsed.type === "success" && parsed.json.effect === "forbid") {
171
+ return "forbid_policy_fired";
172
+ }
173
+ }
174
+ // Fallback: a determining policy exists on Deny but is not a parseable forbid.
175
+ return "forbid_policy_fired";
176
+ }
177
+
178
+ export async function handleAuthorize(input: AuthorizeInput): Promise<AuthorizeResult> {
179
+ // Parse entities first so we can run format detection.
180
+ // Also unwrap the AVP SDK entity_list/entityList envelope:
181
+ // Ruby SDK: { entity_list: [...] }
182
+ // Python/JS: { entityList: [...] }
183
+ // Official API: { entityList: [...] }
184
+ // Users sometimes copy the full SDK entities parameter value rather than just the array.
185
+ let rawEntities: unknown[];
186
+ try {
187
+ const parsed = JSON.parse(input.entities as string);
188
+ if (Array.isArray(parsed)) {
189
+ rawEntities = parsed;
190
+ } else if (typeof parsed === "object" && parsed !== null) {
191
+ const obj = parsed as Record<string, unknown>;
192
+ const list = obj["entity_list"] ?? obj["entityList"] ?? obj["EntityList"];
193
+ if (Array.isArray(list)) {
194
+ rawEntities = list;
195
+ } else {
196
+ throw new Error("not an array");
197
+ }
198
+ } else {
199
+ throw new Error("not an array");
200
+ }
201
+ } catch {
202
+ return DENY_RESULT("entities must be a valid JSON array or an AVP entity_list object");
203
+ }
204
+
205
+ // Detect format across all inputs together
206
+ const detection = detectFormat(rawEntities, input.principal, input.action, input.resource);
207
+
208
+ // Normalize entities and principal/action/resource to WASM format
209
+ const normalizedEntities = normalizeEntities(rawEntities, detection.format);
210
+
211
+ const principalRef = normalizePrincipalRef(input.principal);
212
+ if ("error" in principalRef) return DENY_RESULT(principalRef.error, detection);
213
+
214
+ const actionRef = normalizePrincipalRef(input.action);
215
+ if ("error" in actionRef) return DENY_RESULT(actionRef.error, detection);
216
+
217
+ const resourceRef = normalizePrincipalRef(input.resource);
218
+ if ("error" in resourceRef) return DENY_RESULT(resourceRef.error, detection);
219
+
220
+ if (!input.policies && !input.policiesMap) {
221
+ return DENY_RESULT("policies or policiesMap is required", detection);
222
+ }
223
+
224
+ const built = buildStaticPolicies(input);
225
+ if ("error" in built) return DENY_RESULT(built.error, detection);
226
+ const staticPolicies = built.record;
227
+
228
+ let schema: Schema | undefined;
229
+ if (input.schema) {
230
+ try {
231
+ schema = JSON.parse(input.schema as string);
232
+ } catch {
233
+ schema = input.schema as string;
234
+ }
235
+ }
236
+
237
+ let context: Record<string, CedarValueJson> = {};
238
+ if (input.context) {
239
+ try {
240
+ context = JSON.parse(input.context);
241
+ } catch {
242
+ return DENY_RESULT("context must be a valid JSON object", detection);
243
+ }
244
+ }
245
+
246
+ const call: AuthorizationCall = {
247
+ principal: principalRef,
248
+ action: actionRef,
249
+ resource: resourceRef,
250
+ context,
251
+ policies: { staticPolicies },
252
+ entities: normalizedEntities as Entities,
253
+ ...(schema ? { schema, validateRequest: true } : {}),
254
+ };
255
+
256
+ // per spike-report-wasm-api.md §1: type field is "success"|"failure" for WASM health,
257
+ // decision is "allow"|"deny" for the authorization result
258
+ const answer = isAuthorized(call);
259
+
260
+ if (answer.type === "failure") {
261
+ const errorMessages = answer.errors.map((e) => e.message);
262
+ return {
263
+ decision: "Deny",
264
+ determining_policies: [],
265
+ errors: errorMessages,
266
+ decision_reason: "evaluation_error",
267
+ format_detected: detection.format,
268
+ format_note: detection.note,
269
+ };
270
+ }
271
+
272
+ const { decision, diagnostics } = answer.response;
273
+ const normalizedDecision: "Allow" | "Deny" = decision === "allow" ? "Allow" : "Deny";
274
+ const determining = diagnostics.reason;
275
+ const errorMessages = diagnostics.errors.map((e) => e.error.message);
276
+
277
+ return {
278
+ decision: normalizedDecision,
279
+ determining_policies: determining,
280
+ errors: errorMessages,
281
+ decision_reason: classifyDecisionReason(normalizedDecision, determining, errorMessages, staticPolicies),
282
+ format_detected: detection.format,
283
+ format_note: detection.note,
284
+ };
285
+ }
286
+
287
+ // ─── 10d workspace auto-discovery wrapper ────────────────────────────────────
288
+
289
+ /**
290
+ * Inputs accepted by the MCP-level authorize entry point. Wider than
291
+ * `AuthorizeInput` because it also accepts the `_ref` shapes the MCP layer
292
+ * resolves before reaching `handleAuthorize`. Kept distinct so the WASM-level
293
+ * handler does not need to know about MCP plumbing.
294
+ */
295
+ export interface AuthorizeMcpInput {
296
+ policies?: string;
297
+ policy_ref?: string;
298
+ policiesMap?: Record<string, string>;
299
+ principal: string | Record<string, unknown>;
300
+ action: string | Record<string, unknown>;
301
+ resource: string | Record<string, unknown>;
302
+ entities?: string;
303
+ entities_ref?: string;
304
+ schema?: string;
305
+ schema_ref?: string;
306
+ context?: string;
307
+ store?: string;
308
+ }
309
+
310
+ /**
311
+ * 10d workspace auto-discovery wrapper for `cedar_authorize`.
312
+ *
313
+ * Resolves missing policies / schema / entities from the loaded MCP roots,
314
+ * then delegates to `handleAuthorize`. A single store backs all three so a
315
+ * call never mixes policies from one workspace with entities from another.
316
+ *
317
+ * Multi-store deployments with no explicit `store` parameter surface an
318
+ * ambiguity error in the `{ error }` envelope. None-loaded falls through to
319
+ * the "policies / entities required" errors that the MCP layer already used.
320
+ *
321
+ * Returns either:
322
+ * - `{ result }` -- the AuthorizeResult, with `auto_discovered` set when any
323
+ * input was sourced from the workspace.
324
+ * - `{ error }` -- a string error suitable for the standard `{ error: ... }`
325
+ * MCP envelope.
326
+ *
327
+ * The server.ts handler wraps this and serializes the result back to MCP.
328
+ * Tests can call it directly after setting up the `storeManager` singleton
329
+ * with `loadFromRoots([...])`.
330
+ */
331
+ export async function handleAuthorizeMcp(
332
+ input: AuthorizeMcpInput,
333
+ resolveRef: (uri: string) => { content: string } | { error: string },
334
+ ): Promise<{ result: AuthorizeResult } | { error: string }> {
335
+ const needsAuto =
336
+ (!input.policies && !input.policy_ref && !input.policiesMap) ||
337
+ (!input.schema && !input.schema_ref) ||
338
+ (!input.entities && !input.entities_ref);
339
+
340
+ let autoStore: string | undefined;
341
+ if (needsAuto) {
342
+ if (input.store) {
343
+ if (!storeManager.getStore(input.store)) {
344
+ const available = storeManager.listStoreNames().join(", ") || "none";
345
+ return { error: `Store not found: "${input.store}". Available stores: ${available}.` };
346
+ }
347
+ autoStore = input.store;
348
+ } else {
349
+ const def = storeManager.getDefaultStore();
350
+ if (def.kind === "single") autoStore = def.store.name;
351
+ else if (def.kind === "ambiguous") {
352
+ return { error: `Multiple stores are loaded (${def.names.join(", ")}). Pass store: "<name>" to choose.` };
353
+ }
354
+ // def.kind === "none": leave autoStore undefined and let the
355
+ // "Either X or X_ref is required" branches below fire.
356
+ }
357
+ }
358
+
359
+ // Resolve policy_ref / policies. The cedar://policies/{store} loop pattern
360
+ // keeps each policy's basename as its determining-policies id rather than
361
+ // collapsing the set into a single blob.
362
+ let policies = input.policies;
363
+ let policiesMap = input.policiesMap;
364
+ let policiesFrom: string | undefined;
365
+ if (!policies && !policiesMap && input.policy_ref) {
366
+ const storeMatch = input.policy_ref.match(/^cedar:\/\/policies\/([^/]+)$/);
367
+ const singleMatch = input.policy_ref.match(/^cedar:\/\/policies\/([^/]+)\/([^/]+)$/);
368
+ if (storeMatch) {
369
+ const storeName = storeMatch[1]!;
370
+ try {
371
+ const ids = storeManager.listPolicies(storeName);
372
+ policiesMap = {};
373
+ for (const id of ids) policiesMap[id] = storeManager.readPolicy(storeName, id);
374
+ } catch (e) {
375
+ return { error: e instanceof Error ? e.message : String(e) };
376
+ }
377
+ } else if (singleMatch) {
378
+ const storeName = singleMatch[1]!;
379
+ const policyId = singleMatch[2]!;
380
+ try {
381
+ policiesMap = { [policyId]: storeManager.readPolicy(storeName, policyId) };
382
+ } catch (e) {
383
+ return { error: e instanceof Error ? e.message : String(e) };
384
+ }
385
+ } else {
386
+ const resolved = resolveRef(input.policy_ref);
387
+ if ("error" in resolved) return { error: resolved.error };
388
+ policies = resolved.content;
389
+ }
390
+ }
391
+ if (!policies && !policiesMap && autoStore) {
392
+ try {
393
+ const ids = storeManager.listPolicies(autoStore);
394
+ policiesMap = {};
395
+ for (const id of ids) policiesMap[id] = storeManager.readPolicy(autoStore, id);
396
+ policiesFrom = autoStore;
397
+ } catch (e) {
398
+ return { error: e instanceof Error ? e.message : String(e) };
399
+ }
400
+ }
401
+ if (!policies && !policiesMap) return { error: "Either policies or policy_ref is required" };
402
+
403
+ let schema = input.schema;
404
+ let schemaFrom: string | undefined;
405
+ if (!schema && input.schema_ref) {
406
+ const resolved = resolveRef(input.schema_ref);
407
+ if ("error" in resolved) return { error: resolved.error };
408
+ schema = resolved.content;
409
+ }
410
+ if (!schema && autoStore) {
411
+ try {
412
+ schema = storeManager.readSchema(autoStore);
413
+ schemaFrom = autoStore;
414
+ } catch {
415
+ // Store has no schema file; schema stays undefined (it is optional).
416
+ }
417
+ }
418
+
419
+ let entities = input.entities;
420
+ let entitiesFrom: string | undefined;
421
+ if (!entities && input.entities_ref) {
422
+ const resolved = resolveRef(input.entities_ref);
423
+ if ("error" in resolved) return { error: resolved.error };
424
+ entities = resolved.content;
425
+ }
426
+ if (!entities && autoStore) {
427
+ // Only claim entities_from if the store actually has an entities/
428
+ // subdirectory with files. readAllEntities returns "[]" when the
429
+ // directory is missing, which would otherwise have us lie in
430
+ // auto_discovered.entities_from about the source of zero entities.
431
+ try {
432
+ const entityFiles = storeManager.listEntities(autoStore);
433
+ if (entityFiles.length > 0) {
434
+ entities = storeManager.readAllEntities(autoStore);
435
+ entitiesFrom = autoStore;
436
+ } else {
437
+ entities = "[]";
438
+ }
439
+ } catch (e) {
440
+ return { error: e instanceof Error ? e.message : String(e) };
441
+ }
442
+ }
443
+ if (!entities) return { error: "Either entities or entities_ref is required" };
444
+
445
+ const result = await handleAuthorize({
446
+ policies,
447
+ policiesMap,
448
+ principal: input.principal,
449
+ action: input.action,
450
+ resource: input.resource,
451
+ entities,
452
+ schema,
453
+ context: input.context,
454
+ });
455
+
456
+ const autoDiscovered: { policies_from?: string; schema_from?: string; entities_from?: string } = {};
457
+ if (policiesFrom) autoDiscovered.policies_from = policiesFrom;
458
+ if (schemaFrom) autoDiscovered.schema_from = schemaFrom;
459
+ if (entitiesFrom) autoDiscovered.entities_from = entitiesFrom;
460
+ if (Object.keys(autoDiscovered).length > 0) {
461
+ result.auto_discovered = autoDiscovered;
462
+ }
463
+ return { result };
464
+ }
@@ -0,0 +1,119 @@
1
+ import { policyToJson } from "@cedar-policy/cedar-wasm/nodejs";
2
+ import type { PolicyJson } from "@cedar-policy/cedar-wasm/nodejs";
3
+
4
+ export interface CheckChangeInput {
5
+ old_policy: string;
6
+ new_policy: string;
7
+ }
8
+
9
+ export interface PolicyChange {
10
+ field: "effect" | "principal" | "resource" | "action" | "conditions";
11
+ old_value?: string;
12
+ new_value?: string;
13
+ in_place_allowed: boolean;
14
+ reason: string;
15
+ }
16
+
17
+ export interface CheckChangeResult {
18
+ can_update_in_place: boolean;
19
+ changes: PolicyChange[];
20
+ recommendation: string;
21
+ error?: string;
22
+ }
23
+
24
+ const IN_PLACE_RULES: Record<string, { allowed: boolean; reason: string }> = {
25
+ effect: {
26
+ allowed: false,
27
+ reason: "Changing effect (permit ↔ forbid) requires deleting and recreating the policy.",
28
+ },
29
+ principal: {
30
+ allowed: false,
31
+ reason: "Changing the principal clause requires deleting and recreating the policy.",
32
+ },
33
+ resource: {
34
+ allowed: false,
35
+ reason: "Changing the resource clause requires deleting and recreating the policy.",
36
+ },
37
+ action: {
38
+ allowed: true,
39
+ reason: "Action clause changes can be applied in-place.",
40
+ },
41
+ conditions: {
42
+ allowed: true,
43
+ reason: "Condition clause (when/unless) changes can be applied in-place.",
44
+ },
45
+ };
46
+
47
+ function parsePolicy(text: string): PolicyJson {
48
+ const result = policyToJson(text);
49
+ if (result.type === "failure") {
50
+ throw new Error(result.errors.map((e) => e.message).join("; "));
51
+ }
52
+ return result.json;
53
+ }
54
+
55
+ function stringify(v: unknown): string {
56
+ return JSON.stringify(v);
57
+ }
58
+
59
+ const EMPTY_RESULT: Omit<CheckChangeResult, "error"> = {
60
+ can_update_in_place: false,
61
+ changes: [],
62
+ recommendation: "",
63
+ };
64
+
65
+ export async function handleCheckChange(input: CheckChangeInput): Promise<CheckChangeResult> {
66
+ let oldJson: PolicyJson;
67
+ let newJson: PolicyJson;
68
+ try {
69
+ oldJson = parsePolicy(input.old_policy);
70
+ } catch (e) {
71
+ return { ...EMPTY_RESULT, error: `Failed to parse old_policy: ${e instanceof Error ? e.message : String(e)}` };
72
+ }
73
+ try {
74
+ newJson = parsePolicy(input.new_policy);
75
+ } catch (e) {
76
+ return { ...EMPTY_RESULT, error: `Failed to parse new_policy: ${e instanceof Error ? e.message : String(e)}` };
77
+ }
78
+
79
+ const changes: PolicyChange[] = [];
80
+
81
+ // Compare each field. Effect is a string; others are structured objects.
82
+ const fields: Array<keyof typeof IN_PLACE_RULES> = [
83
+ "effect",
84
+ "principal",
85
+ "resource",
86
+ "action",
87
+ "conditions",
88
+ ];
89
+
90
+ for (const field of fields) {
91
+ const oldVal = field === "effect" ? oldJson.effect : (oldJson as unknown as Record<string, unknown>)[field];
92
+ const newVal = field === "effect" ? newJson.effect : (newJson as unknown as Record<string, unknown>)[field];
93
+
94
+ if (stringify(oldVal) !== stringify(newVal)) {
95
+ const rule = IN_PLACE_RULES[field]!;
96
+ changes.push({
97
+ field: field as PolicyChange["field"],
98
+ old_value: stringify(oldVal),
99
+ new_value: stringify(newVal),
100
+ in_place_allowed: rule.allowed,
101
+ reason: rule.reason,
102
+ });
103
+ }
104
+ }
105
+
106
+ const can_update_in_place = changes.every((c) => c.in_place_allowed);
107
+
108
+ let recommendation: string;
109
+ if (changes.length === 0) {
110
+ recommendation = "No changes detected.";
111
+ } else if (can_update_in_place) {
112
+ recommendation = "All changes can be applied as an in-place policy update.";
113
+ } else {
114
+ const blocking = changes.filter((c) => !c.in_place_allowed).map((c) => c.field);
115
+ recommendation = `Delete the existing policy and create a new one. The following fields cannot be changed in-place: ${blocking.join(", ")}.`;
116
+ }
117
+
118
+ return { can_update_in_place, changes, recommendation };
119
+ }