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,682 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { handleAuthorize, handleAuthorizeMcp } from "../../src/tools/authorize.js";
6
+ import { storeManager } from "../../src/resources/store-manager.js";
7
+ import { POLICIES, SCHEMA_JSON, ENTITIES } from "../fixtures/docmgmt.js";
8
+
9
+ // resolveRef stub: the 10d auto-discovery tests never use `_ref` fields, so
10
+ // any call into this stub indicates a test bug (the auto-discovery flow took
11
+ // an unexpected branch). Surface that loudly rather than returning blank.
12
+ const noResolve = (uri: string): { content: string } | { error: string } => ({
13
+ error: `unexpected resolveRef call in auto-discovery test: ${uri}`,
14
+ });
15
+
16
+ describe("cedar_authorize", () => {
17
+ it("allows alice (admin) to read doc-public", async () => {
18
+ const result = await handleAuthorize({
19
+ policies: POLICIES,
20
+ principal: 'DocMgmt::User::"alice"',
21
+ action: 'DocMgmt::Action::"READ"',
22
+ resource: 'DocMgmt::Document::"doc-public"',
23
+ entities: JSON.stringify(ENTITIES),
24
+ });
25
+
26
+ expect(result.decision).toBe("Allow");
27
+ expect(result.errors).toHaveLength(0);
28
+ });
29
+
30
+ it("denies dave (no role) reading doc-public — default deny", async () => {
31
+ const result = await handleAuthorize({
32
+ policies: POLICIES,
33
+ principal: 'DocMgmt::User::"dave"',
34
+ action: 'DocMgmt::Action::"READ"',
35
+ resource: 'DocMgmt::Document::"doc-public"',
36
+ entities: JSON.stringify(ENTITIES),
37
+ });
38
+
39
+ expect(result.decision).toBe("Deny");
40
+ expect(result.determining_policies).toHaveLength(0);
41
+ expect(result.errors).toHaveLength(0);
42
+ });
43
+
44
+ it("denies bob (editor) reading doc-secret — forbid overrides permit", async () => {
45
+ const result = await handleAuthorize({
46
+ policies: POLICIES,
47
+ principal: 'DocMgmt::User::"bob"',
48
+ action: 'DocMgmt::Action::"READ"',
49
+ resource: 'DocMgmt::Document::"doc-secret"',
50
+ entities: JSON.stringify(ENTITIES),
51
+ });
52
+
53
+ expect(result.decision).toBe("Deny");
54
+ expect(result.errors).toHaveLength(0);
55
+ });
56
+
57
+ it("allows alice (admin) to read doc-secret — admin exempt from forbid", async () => {
58
+ const result = await handleAuthorize({
59
+ policies: POLICIES,
60
+ principal: 'DocMgmt::User::"alice"',
61
+ action: 'DocMgmt::Action::"READ"',
62
+ resource: 'DocMgmt::Document::"doc-secret"',
63
+ entities: JSON.stringify(ENTITIES),
64
+ });
65
+
66
+ expect(result.decision).toBe("Allow");
67
+ expect(result.errors).toHaveLength(0);
68
+ });
69
+
70
+ it("surfaces determining_policies on allow", async () => {
71
+ const result = await handleAuthorize({
72
+ policies: POLICIES,
73
+ principal: 'DocMgmt::User::"charlie"',
74
+ action: 'DocMgmt::Action::"READ"',
75
+ resource: 'DocMgmt::Document::"doc-public"',
76
+ entities: JSON.stringify(ENTITIES),
77
+ });
78
+
79
+ expect(result.decision).toBe("Allow");
80
+ expect(result.determining_policies.length).toBeGreaterThan(0);
81
+ });
82
+
83
+ it("unwraps AVP entity_list envelope (Ruby SDK full entities parameter)", async () => {
84
+ // Ruby SDK sends: entities: { entity_list: [...] }
85
+ // Users who copy the full SDK entities value get this structure
86
+ const result = await handleAuthorize({
87
+ policies: POLICIES,
88
+ principal: 'DocMgmt::User::"alice"',
89
+ action: 'DocMgmt::Action::"READ"',
90
+ resource: 'DocMgmt::Document::"doc-public"',
91
+ entities: JSON.stringify({ entity_list: ENTITIES }),
92
+ });
93
+ expect(result.decision).toBe("Allow");
94
+ expect(result.error).toBeUndefined();
95
+ });
96
+
97
+ it("unwraps AVP entityList envelope (Python/JS SDK full entities parameter)", async () => {
98
+ const result = await handleAuthorize({
99
+ policies: POLICIES,
100
+ principal: 'DocMgmt::User::"alice"',
101
+ action: 'DocMgmt::Action::"READ"',
102
+ resource: 'DocMgmt::Document::"doc-public"',
103
+ entities: JSON.stringify({ entityList: ENTITIES }),
104
+ });
105
+ expect(result.decision).toBe("Allow");
106
+ expect(result.error).toBeUndefined();
107
+ });
108
+
109
+ it("returns structured error for malformed entity reference instead of throwing", async () => {
110
+ const result = await handleAuthorize({
111
+ policies: `permit(principal, action, resource);`,
112
+ principal: "bad-format-no-quotes",
113
+ action: 'DocMgmt::Action::"READ"',
114
+ resource: 'DocMgmt::Document::"doc-public"',
115
+ entities: JSON.stringify(ENTITIES),
116
+ });
117
+
118
+ expect(result.error).toBeDefined();
119
+ expect(result.decision).toBe("Deny");
120
+ });
121
+
122
+ it("returns structured error for invalid entities JSON instead of throwing", async () => {
123
+ const result = await handleAuthorize({
124
+ policies: `permit(principal, action, resource);`,
125
+ principal: 'DocMgmt::User::"alice"',
126
+ action: 'DocMgmt::Action::"READ"',
127
+ resource: 'DocMgmt::Document::"doc-public"',
128
+ entities: "not valid json",
129
+ });
130
+
131
+ expect(result.error).toBeDefined();
132
+ expect(result.error).toContain("entities");
133
+ expect(result.decision).toBe("Deny");
134
+ });
135
+
136
+ it("accepts schema and validates the request", async () => {
137
+ const result = await handleAuthorize({
138
+ policies: POLICIES,
139
+ principal: 'DocMgmt::User::"alice"',
140
+ action: 'DocMgmt::Action::"READ"',
141
+ resource: 'DocMgmt::Document::"doc-public"',
142
+ entities: JSON.stringify(ENTITIES),
143
+ schema: JSON.stringify(SCHEMA_JSON),
144
+ });
145
+
146
+ expect(result.decision).toBe("Allow");
147
+ expect(result.errors).toHaveLength(0);
148
+ });
149
+ });
150
+
151
+ describe("cedar_authorize — format detection and auto-normalization", () => {
152
+ // AVP format entities: identifier key + typed attribute wrappers + entity_type/entity_id parents
153
+ const AVP_ENTITIES = JSON.stringify([
154
+ {
155
+ identifier: { entity_type: "DocMgmt::User", entity_id: "alice" },
156
+ attributes: {
157
+ name: { string: "Alice Smith" },
158
+ email: { string: "alice@example.com" },
159
+ },
160
+ parents: [{ entity_type: "DocMgmt::Role", entity_id: "admin" }],
161
+ },
162
+ {
163
+ identifier: { entity_type: "DocMgmt::User", entity_id: "bob" },
164
+ attributes: {
165
+ name: { string: "Bob Jones" },
166
+ email: { string: "bob@example.com" },
167
+ },
168
+ parents: [{ entity_type: "DocMgmt::Role", entity_id: "editor" }],
169
+ },
170
+ {
171
+ identifier: { entity_type: "DocMgmt::Role", entity_id: "admin" },
172
+ attributes: {},
173
+ parents: [],
174
+ },
175
+ {
176
+ identifier: { entity_type: "DocMgmt::Role", entity_id: "editor" },
177
+ attributes: {},
178
+ parents: [],
179
+ },
180
+ {
181
+ identifier: { entity_type: "DocMgmt::Document", entity_id: "doc-public" },
182
+ attributes: {
183
+ owner: { string: "alice" },
184
+ classification: { string: "public" },
185
+ },
186
+ parents: [],
187
+ },
188
+ {
189
+ identifier: { entity_type: "DocMgmt::Document", entity_id: "doc-secret" },
190
+ attributes: {
191
+ owner: { string: "alice" },
192
+ classification: { string: "top_secret" },
193
+ },
194
+ parents: [],
195
+ },
196
+ ]);
197
+
198
+ // AVP-style principal/action/resource objects
199
+ const avpPrincipal = { entity_type: "DocMgmt::User", entity_id: "alice" };
200
+ const avpAction = { action_type: "DocMgmt::Action", action_id: "READ" };
201
+ const avpResource = { entity_type: "DocMgmt::Document", entity_id: "doc-public" };
202
+
203
+ it("allows alice (admin) using AVP entity format — auto-normalized", async () => {
204
+ const result = await handleAuthorize({
205
+ policies: POLICIES,
206
+ principal: avpPrincipal as unknown as string,
207
+ action: avpAction as unknown as string,
208
+ resource: avpResource as unknown as string,
209
+ entities: AVP_ENTITIES,
210
+ });
211
+
212
+ expect(result.decision).toBe("Allow");
213
+ expect(result.format_detected).toBe("avp");
214
+ expect(result.format_note).toContain("AVP format");
215
+ expect(result.errors).toHaveLength(0);
216
+ });
217
+
218
+ it("denies bob (editor) reading top-secret doc — AVP format, forbid still fires", async () => {
219
+ const result = await handleAuthorize({
220
+ policies: POLICIES,
221
+ principal: { entity_type: "DocMgmt::User", entity_id: "bob" } as unknown as string,
222
+ action: { action_type: "DocMgmt::Action", action_id: "READ" } as unknown as string,
223
+ resource: { entity_type: "DocMgmt::Document", entity_id: "doc-secret" } as unknown as string,
224
+ entities: AVP_ENTITIES,
225
+ });
226
+
227
+ expect(result.decision).toBe("Deny");
228
+ expect(result.format_detected).toBe("avp");
229
+ });
230
+
231
+ it("reports cedar format when Cedar string literals are passed", async () => {
232
+ const result = await handleAuthorize({
233
+ policies: POLICIES,
234
+ principal: 'DocMgmt::User::"alice"',
235
+ action: 'DocMgmt::Action::"READ"',
236
+ resource: 'DocMgmt::Document::"doc-public"',
237
+ entities: JSON.stringify(ENTITIES),
238
+ });
239
+
240
+ expect(result.decision).toBe("Allow");
241
+ expect(result.format_detected).toBe("cedar");
242
+ });
243
+
244
+ it("camelCase AVP format (Python/JS SDK) — auto-normalized and evaluated correctly", async () => {
245
+ const result = await handleAuthorize({
246
+ policies: `permit(principal in DocMgmt::Role::"admin", action, resource);`,
247
+ principal: { entityType: "DocMgmt::User", entityId: "alice" } as unknown as string,
248
+ action: { actionType: "DocMgmt::Action", actionId: "READ" } as unknown as string,
249
+ resource: { entityType: "DocMgmt::Document", entityId: "doc-1" } as unknown as string,
250
+ entities: JSON.stringify([
251
+ {
252
+ identifier: { entityType: "DocMgmt::User", entityId: "alice" },
253
+ attributes: {},
254
+ parents: [{ entityType: "DocMgmt::Role", entityId: "admin" }],
255
+ },
256
+ {
257
+ identifier: { entityType: "DocMgmt::Role", entityId: "admin" },
258
+ attributes: {},
259
+ parents: [],
260
+ },
261
+ {
262
+ identifier: { entityType: "DocMgmt::Document", entityId: "doc-1" },
263
+ attributes: {},
264
+ parents: [],
265
+ },
266
+ ]),
267
+ });
268
+
269
+ expect(result.decision).toBe("Allow");
270
+ expect(result.format_detected).toBe("avp");
271
+ });
272
+
273
+ it("PascalCase AVP format (official API / AWS console) — auto-normalized and evaluated correctly", async () => {
274
+ const result = await handleAuthorize({
275
+ policies: `permit(principal in DocMgmt::Role::"admin", action, resource);`,
276
+ principal: { EntityType: "DocMgmt::User", EntityId: "alice" } as unknown as string,
277
+ action: { ActionType: "DocMgmt::Action", ActionId: "READ" } as unknown as string,
278
+ resource: { EntityType: "DocMgmt::Document", EntityId: "doc-1" } as unknown as string,
279
+ entities: JSON.stringify([
280
+ {
281
+ Identifier: { EntityType: "DocMgmt::User", EntityId: "alice" },
282
+ Attributes: {},
283
+ Parents: [{ EntityType: "DocMgmt::Role", EntityId: "admin" }],
284
+ },
285
+ {
286
+ Identifier: { EntityType: "DocMgmt::Role", EntityId: "admin" },
287
+ Attributes: {},
288
+ Parents: [],
289
+ },
290
+ {
291
+ Identifier: { EntityType: "DocMgmt::Document", EntityId: "doc-1" },
292
+ Attributes: {},
293
+ Parents: [],
294
+ },
295
+ ]),
296
+ });
297
+
298
+ expect(result.decision).toBe("Allow");
299
+ expect(result.format_detected).toBe("avp");
300
+ });
301
+
302
+ it("AVP typed attributes with non-string policy condition work correctly after unwrap", async () => {
303
+ // Policy checks principal.name — must be unwrapped from { string: "alice" } to "alice"
304
+ const policy = `permit(principal, action, resource) when { principal.name == "alice" };`;
305
+ const result = await handleAuthorize({
306
+ policies: policy,
307
+ principal: { entity_type: "MyApp::User", entity_id: "user-1" } as unknown as string,
308
+ action: { action_type: "MyApp::Action", action_id: "READ" } as unknown as string,
309
+ resource: { entity_type: "MyApp::Resource", entity_id: "res-1" } as unknown as string,
310
+ entities: JSON.stringify([
311
+ {
312
+ identifier: { entity_type: "MyApp::User", entity_id: "user-1" },
313
+ attributes: { name: { string: "alice" } },
314
+ parents: [],
315
+ },
316
+ {
317
+ identifier: { entity_type: "MyApp::Resource", entity_id: "res-1" },
318
+ attributes: {},
319
+ parents: [],
320
+ },
321
+ ]),
322
+ });
323
+
324
+ // Without unwrapping: name would be { string: "alice" } (a Record), not "alice" — policy would not match
325
+ expect(result.decision).toBe("Allow");
326
+ expect(result.format_detected).toBe("avp");
327
+ });
328
+ });
329
+
330
+ describe("cedar_authorize — H1 stable policy identifiers in determining_policies", () => {
331
+ it("returns the @id annotation when a permit policy has one", async () => {
332
+ const policies = `
333
+ @id("admin-read")
334
+ permit (
335
+ principal,
336
+ action == DocMgmt::Action::"READ",
337
+ resource
338
+ );
339
+ `.trim();
340
+ const result = await handleAuthorize({
341
+ policies,
342
+ principal: 'DocMgmt::User::"alice"',
343
+ action: 'DocMgmt::Action::"READ"',
344
+ resource: 'DocMgmt::Document::"doc-public"',
345
+ entities: JSON.stringify(ENTITIES),
346
+ });
347
+ expect(result.decision).toBe("Allow");
348
+ expect(result.determining_policies).toEqual(["admin-read"]);
349
+ });
350
+
351
+ it("falls back to policy<index> when no @id annotation and no file basename is known", async () => {
352
+ const policies = `permit (principal, action, resource);`;
353
+ const result = await handleAuthorize({
354
+ policies,
355
+ principal: 'DocMgmt::User::"alice"',
356
+ action: 'DocMgmt::Action::"READ"',
357
+ resource: 'DocMgmt::Document::"doc-public"',
358
+ entities: JSON.stringify(ENTITIES),
359
+ });
360
+ expect(result.decision).toBe("Allow");
361
+ expect(result.determining_policies).toEqual(["policy0"]);
362
+ });
363
+
364
+ it("uses the policiesMap key (file basename) as the determining policy id", async () => {
365
+ const result = await handleAuthorize({
366
+ policiesMap: {
367
+ admin: `permit (principal in DocMgmt::Role::"admin", action, resource);`,
368
+ },
369
+ principal: 'DocMgmt::User::"alice"',
370
+ action: 'DocMgmt::Action::"READ"',
371
+ resource: 'DocMgmt::Document::"doc-public"',
372
+ entities: JSON.stringify(ENTITIES),
373
+ });
374
+ expect(result.decision).toBe("Allow");
375
+ expect(result.determining_policies).toEqual(["admin"]);
376
+ });
377
+
378
+ it("prefers @id annotation over the policiesMap key when both are present", async () => {
379
+ const result = await handleAuthorize({
380
+ policiesMap: {
381
+ admin: `@id("admin-read-all")\npermit (principal in DocMgmt::Role::"admin", action, resource);`,
382
+ },
383
+ principal: 'DocMgmt::User::"alice"',
384
+ action: 'DocMgmt::Action::"READ"',
385
+ resource: 'DocMgmt::Document::"doc-public"',
386
+ entities: JSON.stringify(ENTITIES),
387
+ });
388
+ expect(result.decision).toBe("Allow");
389
+ expect(result.determining_policies).toEqual(["admin-read-all"]);
390
+ });
391
+ });
392
+
393
+ describe("cedar_authorize — M3 decision_reason field", () => {
394
+ it("returns decision_reason = permit_policy_fired on Allow", async () => {
395
+ const result = await handleAuthorize({
396
+ policies: POLICIES,
397
+ principal: 'DocMgmt::User::"alice"',
398
+ action: 'DocMgmt::Action::"READ"',
399
+ resource: 'DocMgmt::Document::"doc-public"',
400
+ entities: JSON.stringify(ENTITIES),
401
+ });
402
+ expect(result.decision).toBe("Allow");
403
+ expect(result.decision_reason).toBe("permit_policy_fired");
404
+ });
405
+
406
+ it("returns decision_reason = default_deny_no_permit_matched when no policy fires", async () => {
407
+ const result = await handleAuthorize({
408
+ policies: POLICIES,
409
+ principal: 'DocMgmt::User::"dave"',
410
+ action: 'DocMgmt::Action::"READ"',
411
+ resource: 'DocMgmt::Document::"doc-public"',
412
+ entities: JSON.stringify(ENTITIES),
413
+ });
414
+ expect(result.decision).toBe("Deny");
415
+ expect(result.determining_policies).toHaveLength(0);
416
+ expect(result.decision_reason).toBe("default_deny_no_permit_matched");
417
+ });
418
+
419
+ it("returns decision_reason = forbid_policy_fired when a forbid policy is determining", async () => {
420
+ const result = await handleAuthorize({
421
+ policies: POLICIES,
422
+ principal: 'DocMgmt::User::"bob"',
423
+ action: 'DocMgmt::Action::"READ"',
424
+ resource: 'DocMgmt::Document::"doc-secret"',
425
+ entities: JSON.stringify(ENTITIES),
426
+ });
427
+ expect(result.decision).toBe("Deny");
428
+ expect(result.decision_reason).toBe("forbid_policy_fired");
429
+ });
430
+
431
+ it("returns decision_reason = evaluation_error when a policy errors during evaluation", async () => {
432
+ // Policy reads principal.missing — entity lacks that attribute, causing an evaluation error.
433
+ const policies = `permit(principal, action, resource) when { principal.missing == "x" };`;
434
+ const result = await handleAuthorize({
435
+ policies,
436
+ principal: 'DocMgmt::User::"alice"',
437
+ action: 'DocMgmt::Action::"READ"',
438
+ resource: 'DocMgmt::Document::"doc-public"',
439
+ entities: JSON.stringify(ENTITIES),
440
+ });
441
+ expect(result.errors.length).toBeGreaterThan(0);
442
+ expect(result.decision_reason).toBe("evaluation_error");
443
+ });
444
+ });
445
+
446
+ describe("cedar_authorize — 10c empirical response shape snapshots (H1 + M3 contract)", () => {
447
+ // Round 3 (2026-05-22) doubted whether determining_policies returned stable
448
+ // basenames (H1) and whether decision_reason was actually populated (M3).
449
+ // These snapshot tests lock in the literal response shape for the canonical
450
+ // cases so a future dogfood reviewer can read the test and see the contract,
451
+ // rather than reasoning about it from the Cedar SDK alone.
452
+
453
+ const ADMIN_POLICY = `permit (principal in DocMgmt::Role::"admin", action, resource);`;
454
+ const FORBID_TOPSECRET = `forbid (principal, action, resource) when { resource.classification == "top_secret" } unless { principal in DocMgmt::Role::"admin" };`;
455
+
456
+ it("Allow via admin role: determining_policies uses the policiesMap basename, decision_reason = permit_policy_fired", async () => {
457
+ const result = await handleAuthorize({
458
+ policiesMap: { admin: ADMIN_POLICY },
459
+ principal: 'DocMgmt::User::"alice"',
460
+ action: 'DocMgmt::Action::"READ"',
461
+ resource: 'DocMgmt::Document::"doc-public"',
462
+ entities: JSON.stringify(ENTITIES),
463
+ });
464
+ expect(result).toMatchInlineSnapshot(`
465
+ {
466
+ "decision": "Allow",
467
+ "decision_reason": "permit_policy_fired",
468
+ "determining_policies": [
469
+ "admin",
470
+ ],
471
+ "errors": [],
472
+ "format_detected": "cedar",
473
+ "format_note": "Input is in Cedar/WASM format.",
474
+ }
475
+ `);
476
+ });
477
+
478
+ it("Default deny (no policy fires): determining_policies is empty, decision_reason = default_deny_no_permit_matched", async () => {
479
+ const result = await handleAuthorize({
480
+ policiesMap: { admin: ADMIN_POLICY },
481
+ principal: 'DocMgmt::User::"dave"',
482
+ action: 'DocMgmt::Action::"READ"',
483
+ resource: 'DocMgmt::Document::"doc-public"',
484
+ entities: JSON.stringify(ENTITIES),
485
+ });
486
+ expect(result).toMatchInlineSnapshot(`
487
+ {
488
+ "decision": "Deny",
489
+ "decision_reason": "default_deny_no_permit_matched",
490
+ "determining_policies": [],
491
+ "errors": [],
492
+ "format_detected": "cedar",
493
+ "format_note": "Input is in Cedar/WASM format.",
494
+ }
495
+ `);
496
+ });
497
+
498
+ it("Forbid fires for non-admin on top_secret: determining_policies surfaces forbid id, decision_reason = forbid_policy_fired", async () => {
499
+ const result = await handleAuthorize({
500
+ policiesMap: {
501
+ admin: ADMIN_POLICY,
502
+ "forbid-topsecret": FORBID_TOPSECRET,
503
+ },
504
+ principal: 'DocMgmt::User::"bob"',
505
+ action: 'DocMgmt::Action::"READ"',
506
+ resource: 'DocMgmt::Document::"doc-secret"',
507
+ entities: JSON.stringify(ENTITIES),
508
+ });
509
+ expect(result).toMatchInlineSnapshot(`
510
+ {
511
+ "decision": "Deny",
512
+ "decision_reason": "forbid_policy_fired",
513
+ "determining_policies": [
514
+ "forbid-topsecret",
515
+ ],
516
+ "errors": [],
517
+ "format_detected": "cedar",
518
+ "format_note": "Input is in Cedar/WASM format.",
519
+ }
520
+ `);
521
+ });
522
+ });
523
+
524
+ describe("cedar_authorize — 10d auto-discovery", () => {
525
+ const tempDirs: string[] = [];
526
+
527
+ // Minimal Cedar workspace fixture: schema + one permit-admin policy + entities
528
+ // covering alice (admin) and doc-public. Exercises all three auto-discovery
529
+ // axes (policies / schema / entities) so a successful Allow demonstrates the
530
+ // wrapper resolved every missing input from the workspace.
531
+ const SCHEMA_TEXT = `namespace DocMgmt {
532
+ entity User in [Role] = { name: String, email: String };
533
+ entity Role;
534
+ entity Document in [Folder] = { owner: String, classification: String };
535
+ entity Folder;
536
+ action READ appliesTo { principal: [User], resource: [Document], context: {} };
537
+ action WRITE appliesTo { principal: [User], resource: [Document], context: {} };
538
+ action DELETE appliesTo { principal: [User], resource: [Document], context: {} };
539
+ }`;
540
+
541
+ function makeWorkspace(): string {
542
+ const dir = mkdtempSync(join(tmpdir(), "cedar-authorize-auto-"));
543
+ mkdirSync(join(dir, "policies"), { recursive: true });
544
+ mkdirSync(join(dir, "entities"), { recursive: true });
545
+ writeFileSync(
546
+ join(dir, "policies", "admin.cedar"),
547
+ `permit (principal in DocMgmt::Role::"admin", action, resource);`,
548
+ );
549
+ writeFileSync(join(dir, "schema.cedarschema"), SCHEMA_TEXT);
550
+ writeFileSync(join(dir, "entities", "world.json"), JSON.stringify(ENTITIES));
551
+ return dir;
552
+ }
553
+
554
+ afterEach(() => {
555
+ // Reset the singleton so per-test state never leaks into other suites.
556
+ storeManager.loadFromRoots([]);
557
+ while (tempDirs.length > 0) {
558
+ const dir = tempDirs.pop()!;
559
+ rmSync(dir, { recursive: true, force: true });
560
+ }
561
+ });
562
+
563
+ it("single store loaded auto-pulls policies, schema, and entities (alice admin Allow)", async () => {
564
+ const ws = makeWorkspace();
565
+ tempDirs.push(ws);
566
+ storeManager.loadFromRoots([{ uri: `file://${ws}`, name: "workspace" }]);
567
+
568
+ const outcome = await handleAuthorizeMcp(
569
+ {
570
+ principal: 'DocMgmt::User::"alice"',
571
+ action: 'DocMgmt::Action::"READ"',
572
+ resource: 'DocMgmt::Document::"doc-public"',
573
+ },
574
+ noResolve,
575
+ );
576
+
577
+ expect("error" in outcome).toBe(false);
578
+ if ("error" in outcome) return;
579
+ expect(outcome.result.decision).toBe("Allow");
580
+ expect(outcome.result.errors).toEqual([]);
581
+ expect(outcome.result.determining_policies).toEqual(["admin"]);
582
+ expect(outcome.result.auto_discovered).toEqual({
583
+ policies_from: "workspace",
584
+ schema_from: "workspace",
585
+ entities_from: "workspace",
586
+ });
587
+ });
588
+
589
+ it("honors an explicit store parameter when multiple stores are loaded", async () => {
590
+ const blue = makeWorkspace();
591
+ const green = makeWorkspace();
592
+ tempDirs.push(blue, green);
593
+ storeManager.loadFromRoots([
594
+ { uri: `file://${blue}`, name: "blue" },
595
+ { uri: `file://${green}`, name: "green" },
596
+ ]);
597
+
598
+ const outcome = await handleAuthorizeMcp(
599
+ {
600
+ principal: 'DocMgmt::User::"alice"',
601
+ action: 'DocMgmt::Action::"READ"',
602
+ resource: 'DocMgmt::Document::"doc-public"',
603
+ store: "green",
604
+ },
605
+ noResolve,
606
+ );
607
+
608
+ expect("error" in outcome).toBe(false);
609
+ if ("error" in outcome) return;
610
+ expect(outcome.result.decision).toBe("Allow");
611
+ expect(outcome.result.auto_discovered).toEqual({
612
+ policies_from: "green",
613
+ schema_from: "green",
614
+ entities_from: "green",
615
+ });
616
+ });
617
+
618
+ it("does NOT claim entities_from when the store has no entities/ directory (audit finding)", async () => {
619
+ // Post-phase audit probe: a workspace store without an entities/
620
+ // subdirectory makes readAllEntities return "[]" silently. The earlier
621
+ // wrapper would still set auto_discovered.entities_from = <store>, lying
622
+ // about the source. Decisions evaluated against zero entities are nearly
623
+ // always wrong (a "permit in Role::admin" rule cannot fire if no Role
624
+ // entities exist), so the response must accurately reflect that no
625
+ // entities came from the workspace.
626
+ const dir = mkdtempSync(join(tmpdir(), "cedar-authorize-no-entities-"));
627
+ tempDirs.push(dir);
628
+ mkdirSync(join(dir, "policies"), { recursive: true });
629
+ writeFileSync(
630
+ join(dir, "policies", "always-permit.cedar"),
631
+ `permit (principal, action, resource);`,
632
+ );
633
+ writeFileSync(join(dir, "schema.cedarschema"), SCHEMA_TEXT);
634
+ // NOTE: no entities/ subdirectory on purpose.
635
+
636
+ storeManager.loadFromRoots([{ uri: `file://${dir}`, name: "no-entities-store" }]);
637
+
638
+ const outcome = await handleAuthorizeMcp(
639
+ {
640
+ principal: 'DocMgmt::User::"alice"',
641
+ action: 'DocMgmt::Action::"READ"',
642
+ resource: 'DocMgmt::Document::"doc-public"',
643
+ },
644
+ noResolve,
645
+ );
646
+
647
+ expect("error" in outcome).toBe(false);
648
+ if ("error" in outcome) return;
649
+ expect(outcome.result.auto_discovered).toEqual({
650
+ policies_from: "no-entities-store",
651
+ schema_from: "no-entities-store",
652
+ // entities_from intentionally absent: the store has no entities/
653
+ });
654
+ expect(outcome.result.auto_discovered?.entities_from).toBeUndefined();
655
+ });
656
+
657
+ it("returns an ambiguity error when multiple stores are loaded and no store is passed", async () => {
658
+ const blue = makeWorkspace();
659
+ const green = makeWorkspace();
660
+ tempDirs.push(blue, green);
661
+ storeManager.loadFromRoots([
662
+ { uri: `file://${blue}`, name: "blue" },
663
+ { uri: `file://${green}`, name: "green" },
664
+ ]);
665
+
666
+ const outcome = await handleAuthorizeMcp(
667
+ {
668
+ principal: 'DocMgmt::User::"alice"',
669
+ action: 'DocMgmt::Action::"READ"',
670
+ resource: 'DocMgmt::Document::"doc-public"',
671
+ },
672
+ noResolve,
673
+ );
674
+
675
+ expect("error" in outcome).toBe(true);
676
+ if (!("error" in outcome)) return;
677
+ expect(outcome.error).toMatch(/Multiple stores are loaded/);
678
+ expect(outcome.error).toContain("blue");
679
+ expect(outcome.error).toContain("green");
680
+ expect(outcome.error).toMatch(/Pass store/);
681
+ });
682
+ });