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.
- package/.editorconfig +12 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +42 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +241 -0
- package/CONTRIBUTING.md +83 -0
- package/LICENSE +182 -0
- package/README.md +1635 -0
- package/SECURITY.md +37 -0
- package/dist/http-server.d.ts +61 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +194 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +270 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/policy-ast.d.ts +49 -0
- package/dist/parser/policy-ast.d.ts.map +1 -0
- package/dist/parser/policy-ast.js +311 -0
- package/dist/parser/policy-ast.js.map +1 -0
- package/dist/prompts/index.d.ts +38 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +172 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/resources/ref-resolver.d.ts +23 -0
- package/dist/resources/ref-resolver.d.ts.map +1 -0
- package/dist/resources/ref-resolver.js +128 -0
- package/dist/resources/ref-resolver.js.map +1 -0
- package/dist/resources/store-manager.d.ts +64 -0
- package/dist/resources/store-manager.d.ts.map +1 -0
- package/dist/resources/store-manager.js +221 -0
- package/dist/resources/store-manager.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +539 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/advise/avp-rules.d.ts +49 -0
- package/dist/tools/advise/avp-rules.d.ts.map +1 -0
- package/dist/tools/advise/avp-rules.js +59 -0
- package/dist/tools/advise/avp-rules.js.map +1 -0
- package/dist/tools/advise/cedar-patterns.d.ts +24 -0
- package/dist/tools/advise/cedar-patterns.d.ts.map +1 -0
- package/dist/tools/advise/cedar-patterns.js +57 -0
- package/dist/tools/advise/cedar-patterns.js.map +1 -0
- package/dist/tools/advise/context-builder.d.ts +28 -0
- package/dist/tools/advise/context-builder.d.ts.map +1 -0
- package/dist/tools/advise/context-builder.js +89 -0
- package/dist/tools/advise/context-builder.js.map +1 -0
- package/dist/tools/advise/gotchas.d.ts +15 -0
- package/dist/tools/advise/gotchas.d.ts.map +1 -0
- package/dist/tools/advise/gotchas.js +83 -0
- package/dist/tools/advise/gotchas.js.map +1 -0
- package/dist/tools/advise.d.ts +96 -0
- package/dist/tools/advise.d.ts.map +1 -0
- package/dist/tools/advise.js +258 -0
- package/dist/tools/advise.js.map +1 -0
- package/dist/tools/authorize-batch.d.ts +35 -0
- package/dist/tools/authorize-batch.d.ts.map +1 -0
- package/dist/tools/authorize-batch.js +262 -0
- package/dist/tools/authorize-batch.js.map +1 -0
- package/dist/tools/authorize.d.ts +115 -0
- package/dist/tools/authorize.d.ts.map +1 -0
- package/dist/tools/authorize.js +373 -0
- package/dist/tools/authorize.js.map +1 -0
- package/dist/tools/check-change.d.ts +19 -0
- package/dist/tools/check-change.d.ts.map +1 -0
- package/dist/tools/check-change.js +91 -0
- package/dist/tools/check-change.js.map +1 -0
- package/dist/tools/diff-schema.d.ts +103 -0
- package/dist/tools/diff-schema.d.ts.map +1 -0
- package/dist/tools/diff-schema.js +379 -0
- package/dist/tools/diff-schema.js.map +1 -0
- package/dist/tools/diff-stores.d.ts +45 -0
- package/dist/tools/diff-stores.d.ts.map +1 -0
- package/dist/tools/diff-stores.js +222 -0
- package/dist/tools/diff-stores.js.map +1 -0
- package/dist/tools/explain.d.ts +80 -0
- package/dist/tools/explain.d.ts.map +1 -0
- package/dist/tools/explain.js +187 -0
- package/dist/tools/explain.js.map +1 -0
- package/dist/tools/format.d.ts +11 -0
- package/dist/tools/format.d.ts.map +1 -0
- package/dist/tools/format.js +20 -0
- package/dist/tools/format.js.map +1 -0
- package/dist/tools/generate-sample.d.ts +28 -0
- package/dist/tools/generate-sample.d.ts.map +1 -0
- package/dist/tools/generate-sample.js +568 -0
- package/dist/tools/generate-sample.js.map +1 -0
- package/dist/tools/link-template.d.ts +17 -0
- package/dist/tools/link-template.d.ts.map +1 -0
- package/dist/tools/link-template.js +78 -0
- package/dist/tools/link-template.js.map +1 -0
- package/dist/tools/list-template-links.d.ts +16 -0
- package/dist/tools/list-template-links.d.ts.map +1 -0
- package/dist/tools/list-template-links.js +22 -0
- package/dist/tools/list-template-links.js.map +1 -0
- package/dist/tools/list-templates.d.ts +16 -0
- package/dist/tools/list-templates.d.ts.map +1 -0
- package/dist/tools/list-templates.js +36 -0
- package/dist/tools/list-templates.js.map +1 -0
- package/dist/tools/translate.d.ts +11 -0
- package/dist/tools/translate.d.ts.map +1 -0
- package/dist/tools/translate.js +53 -0
- package/dist/tools/translate.js.map +1 -0
- package/dist/tools/validate-entities.d.ts +19 -0
- package/dist/tools/validate-entities.d.ts.map +1 -0
- package/dist/tools/validate-entities.js +88 -0
- package/dist/tools/validate-entities.js.map +1 -0
- package/dist/tools/validate-schema.d.ts +22 -0
- package/dist/tools/validate-schema.d.ts.map +1 -0
- package/dist/tools/validate-schema.js +89 -0
- package/dist/tools/validate-schema.js.map +1 -0
- package/dist/tools/validate-template.d.ts +18 -0
- package/dist/tools/validate-template.d.ts.map +1 -0
- package/dist/tools/validate-template.js +59 -0
- package/dist/tools/validate-template.js.map +1 -0
- package/dist/tools/validate.d.ts +90 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +351 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/utils/format-detector.d.ts +49 -0
- package/dist/utils/format-detector.d.ts.map +1 -0
- package/dist/utils/format-detector.js +298 -0
- package/dist/utils/format-detector.js.map +1 -0
- package/examples/README.md +36 -0
- package/examples/abac-multi-tenant/README.md +150 -0
- package/examples/abac-multi-tenant/entities/users-and-docs.json +33 -0
- package/examples/abac-multi-tenant/policies/member-read-internal.cedar +9 -0
- package/examples/abac-multi-tenant/policies/owner-full-access.cedar +9 -0
- package/examples/abac-multi-tenant/policies/premium-share-guard.cedar +9 -0
- package/examples/abac-multi-tenant/policies/private-doc-guard.cedar +13 -0
- package/examples/abac-multi-tenant/run.ts +92 -0
- package/examples/abac-multi-tenant/schema.json +60 -0
- package/examples/api-gateway-path-routing/README.md +154 -0
- package/examples/api-gateway-path-routing/entities/users-and-roles.json +20 -0
- package/examples/api-gateway-path-routing/policies/admin-full-access.cedar +6 -0
- package/examples/api-gateway-path-routing/policies/developer-projects.cedar +14 -0
- package/examples/api-gateway-path-routing/policies/viewer-readonly.cedar +10 -0
- package/examples/api-gateway-path-routing/run.ts +108 -0
- package/examples/api-gateway-path-routing/schema.json +54 -0
- package/examples/rbac-document-management/README.md +167 -0
- package/examples/rbac-document-management/entities/users-and-docs.json +43 -0
- package/examples/rbac-document-management/policies/admin.cedar +6 -0
- package/examples/rbac-document-management/policies/editor.cedar +6 -0
- package/examples/rbac-document-management/policies/top-secret-forbid.cedar +13 -0
- package/examples/rbac-document-management/policies/viewer.cedar +6 -0
- package/examples/rbac-document-management/run.ts +87 -0
- package/examples/rbac-document-management/schema.json +57 -0
- package/package.json +50 -0
- package/src/http-server.ts +239 -0
- package/src/index.ts +294 -0
- package/src/parser/policy-ast.ts +345 -0
- package/src/prompts/README.md +3 -0
- package/src/prompts/index.ts +217 -0
- package/src/resources/ref-resolver.ts +134 -0
- package/src/resources/store-manager.ts +248 -0
- package/src/server.ts +711 -0
- package/src/tools/advise/avp-rules.ts +70 -0
- package/src/tools/advise/cedar-patterns.ts +73 -0
- package/src/tools/advise/context-builder.ts +109 -0
- package/src/tools/advise/gotchas.ts +92 -0
- package/src/tools/advise.ts +366 -0
- package/src/tools/authorize-batch.ts +345 -0
- package/src/tools/authorize.ts +464 -0
- package/src/tools/check-change.ts +119 -0
- package/src/tools/diff-schema.ts +510 -0
- package/src/tools/diff-stores.ts +298 -0
- package/src/tools/explain.ts +278 -0
- package/src/tools/format.ts +33 -0
- package/src/tools/generate-sample.ts +665 -0
- package/src/tools/link-template.ts +109 -0
- package/src/tools/list-template-links.ts +41 -0
- package/src/tools/list-templates.ts +55 -0
- package/src/tools/translate.ts +66 -0
- package/src/tools/validate-entities.ts +125 -0
- package/src/tools/validate-schema.ts +128 -0
- package/src/tools/validate-template.ts +72 -0
- package/src/tools/validate.ts +459 -0
- package/src/utils/format-detector.ts +356 -0
- package/test/fixtures/docmgmt.ts +121 -0
- package/test/fixtures/multitenant.ts +163 -0
- package/test/index.test.ts +96 -0
- package/test/integration/e2e/behavior.test.ts +359 -0
- package/test/integration/e2e/edge-cases.test.ts +365 -0
- package/test/integration/e2e/failure-modes.test.ts +266 -0
- package/test/integration/e2e/protocol.test.ts +252 -0
- package/test/integration/http-smoke.test.ts +588 -0
- package/test/integration/smoke.test.ts +475 -0
- package/test/prompts/prompts.test.ts +173 -0
- package/test/property/properties.test.ts +234 -0
- package/test/resources/ref-resolver.test.ts +186 -0
- package/test/resources/store-manager.test.ts +344 -0
- package/test/setup.test.ts +7 -0
- package/test/tools/advise/avp-rules.test.ts +76 -0
- package/test/tools/advise.test.ts +339 -0
- package/test/tools/authorize-batch.test.ts +459 -0
- package/test/tools/authorize.test.ts +682 -0
- package/test/tools/check-change.test.ts +104 -0
- package/test/tools/cross-fixture.test.ts +170 -0
- package/test/tools/diff-schema.test.ts +355 -0
- package/test/tools/diff-stores.test.ts +291 -0
- package/test/tools/explain.test.ts +221 -0
- package/test/tools/format.test.ts +33 -0
- package/test/tools/generate-sample.test.ts +480 -0
- package/test/tools/link-template.test.ts +90 -0
- package/test/tools/list-templates.test.ts +151 -0
- package/test/tools/translate.test.ts +89 -0
- package/test/tools/validate-entities.test.ts +178 -0
- package/test/tools/validate-schema.test.ts +86 -0
- package/test/tools/validate-template.test.ts +89 -0
- package/test/tools/validate.test.ts +331 -0
- package/test/utils/format-detector.test.ts +518 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { handleGenerateSample } from "../../src/tools/generate-sample.js";
|
|
3
|
+
import { SCHEMA_JSON } from "../fixtures/docmgmt.js";
|
|
4
|
+
|
|
5
|
+
const DOCMGMT_SCHEMA_STR = JSON.stringify(SCHEMA_JSON);
|
|
6
|
+
|
|
7
|
+
// Generic ABAC schema for cases 5.2-5.5
|
|
8
|
+
const ABAC_SCHEMA = JSON.stringify({
|
|
9
|
+
MyApp: {
|
|
10
|
+
entityTypes: {
|
|
11
|
+
User: {
|
|
12
|
+
memberOfTypes: [],
|
|
13
|
+
shape: {
|
|
14
|
+
type: "Record",
|
|
15
|
+
attributes: {
|
|
16
|
+
name: { type: "String", required: true },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
Resource: {
|
|
21
|
+
memberOfTypes: [],
|
|
22
|
+
shape: {
|
|
23
|
+
type: "Record",
|
|
24
|
+
attributes: {
|
|
25
|
+
type: { type: "String", required: true },
|
|
26
|
+
region: { type: "String", required: true },
|
|
27
|
+
tag: { type: "String", required: false },
|
|
28
|
+
status: { type: "String", required: false },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
actions: {
|
|
34
|
+
READ: {
|
|
35
|
+
appliesTo: {
|
|
36
|
+
principalTypes: ["User"],
|
|
37
|
+
resourceTypes: ["Resource"],
|
|
38
|
+
context: { type: "Record", attributes: {} },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("cedar_generate_sample_request", () => {
|
|
46
|
+
it("5.1 — simple RBAC: generates allow request for admin role", async () => {
|
|
47
|
+
const result = await handleGenerateSample({
|
|
48
|
+
policy: `permit(principal in DocMgmt::Role::"admin", action, resource);`,
|
|
49
|
+
schema: DOCMGMT_SCHEMA_STR,
|
|
50
|
+
target_decision: "allow",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result.error).toBeUndefined();
|
|
54
|
+
expect(result.decision).toBe("Allow");
|
|
55
|
+
expect(result.ready_to_test).toBe(true);
|
|
56
|
+
expect(result.entities.some((e: { uid: { type: string } }) => e.uid.type === "DocMgmt::Role")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("5.2 — ABAC: generates allow request satisfying all conditions", async () => {
|
|
60
|
+
const result = await handleGenerateSample({
|
|
61
|
+
policy: `permit(
|
|
62
|
+
principal,
|
|
63
|
+
action in [MyApp::Action::"READ"],
|
|
64
|
+
resource
|
|
65
|
+
) when {
|
|
66
|
+
principal.name == "service_x" &&
|
|
67
|
+
resource.type == "report" &&
|
|
68
|
+
resource.region == "us-east"
|
|
69
|
+
};`,
|
|
70
|
+
schema: ABAC_SCHEMA,
|
|
71
|
+
target_decision: "allow",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.error).toBeUndefined();
|
|
75
|
+
expect(result.decision).toBe("Allow");
|
|
76
|
+
expect(result.ready_to_test).toBe(true);
|
|
77
|
+
// Principal should have name = "service_x"
|
|
78
|
+
const principal = result.entities.find((e: { uid: { id: string } }) => e.uid.id === result.principal.split("::")?.[2]?.replace(/"/g, "") || e.uid.type?.includes("Identity"));
|
|
79
|
+
expect(principal).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("5.3 — ABAC: generates deny request violating exactly one condition", async () => {
|
|
83
|
+
const result = await handleGenerateSample({
|
|
84
|
+
policy: `permit(
|
|
85
|
+
principal,
|
|
86
|
+
action in [MyApp::Action::"READ"],
|
|
87
|
+
resource
|
|
88
|
+
) when {
|
|
89
|
+
principal.name == "service_x" &&
|
|
90
|
+
resource.type == "report" &&
|
|
91
|
+
resource.region == "us-east"
|
|
92
|
+
};`,
|
|
93
|
+
schema: ABAC_SCHEMA,
|
|
94
|
+
target_decision: "deny",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(result.error).toBeUndefined();
|
|
98
|
+
expect(result.decision).toBe("Deny");
|
|
99
|
+
expect(result.ready_to_test).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("5.4 — optional attribute guard: allow request includes the optional attribute", async () => {
|
|
103
|
+
const result = await handleGenerateSample({
|
|
104
|
+
policy: `permit(
|
|
105
|
+
principal,
|
|
106
|
+
action in [MyApp::Action::"READ"],
|
|
107
|
+
resource
|
|
108
|
+
) when {
|
|
109
|
+
principal.name == "service_x" &&
|
|
110
|
+
resource has tag &&
|
|
111
|
+
resource.tag == "confidential"
|
|
112
|
+
};`,
|
|
113
|
+
schema: ABAC_SCHEMA,
|
|
114
|
+
target_decision: "allow",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.error).toBeUndefined();
|
|
118
|
+
expect(result.decision).toBe("Allow");
|
|
119
|
+
const resource = result.entities.find((e: { uid: { type: string } }) => e.uid.type?.includes("Resource"));
|
|
120
|
+
expect(resource?.attrs?.tag).toBe("confidential");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("5.5 — optional attribute guard: deny request omits the guarded attribute", async () => {
|
|
124
|
+
const result = await handleGenerateSample({
|
|
125
|
+
policy: `permit(
|
|
126
|
+
principal,
|
|
127
|
+
action in [MyApp::Action::"READ"],
|
|
128
|
+
resource
|
|
129
|
+
) when {
|
|
130
|
+
principal.name == "service_x" &&
|
|
131
|
+
resource has tag &&
|
|
132
|
+
resource.tag == "confidential"
|
|
133
|
+
};`,
|
|
134
|
+
schema: ABAC_SCHEMA,
|
|
135
|
+
target_decision: "deny",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result.error).toBeUndefined();
|
|
139
|
+
expect(result.decision).toBe("Deny");
|
|
140
|
+
const resource = result.entities.find((e: { uid: { type: string } }) => e.uid.type?.includes("Resource"));
|
|
141
|
+
// The resource should NOT have category (omitting the optional attr is the deny strategy)
|
|
142
|
+
expect(resource?.attrs?.tag).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Fix 2: required schema attributes are populated on generated entities
|
|
146
|
+
it("populates required schema attributes even when not mentioned in policy conditions", async () => {
|
|
147
|
+
// The DocMgmt schema requires name+email on User and owner+classification on Document
|
|
148
|
+
// The policy only checks role membership — no condition references these attrs
|
|
149
|
+
// Without the fix, generated entities miss required attrs and validateRequest: true fails
|
|
150
|
+
const result = await handleGenerateSample({
|
|
151
|
+
policy: `permit(principal in DocMgmt::Role::"admin", action, resource);`,
|
|
152
|
+
schema: DOCMGMT_SCHEMA_STR,
|
|
153
|
+
target_decision: "allow",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result.error).toBeUndefined();
|
|
157
|
+
expect(result.decision).toBe("Allow");
|
|
158
|
+
|
|
159
|
+
const principal = result.entities.find((e: { uid: { type: string } }) =>
|
|
160
|
+
e.uid.type?.includes("User")
|
|
161
|
+
);
|
|
162
|
+
const resource = result.entities.find((e: { uid: { type: string } }) =>
|
|
163
|
+
e.uid.type?.includes("Document")
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Required attrs from schema: User has name (String) and email (String)
|
|
167
|
+
expect(principal?.attrs).toHaveProperty("name");
|
|
168
|
+
expect(principal?.attrs).toHaveProperty("email");
|
|
169
|
+
// Required attrs from schema: Document has owner (String) and classification (String)
|
|
170
|
+
expect(resource?.attrs).toHaveProperty("owner");
|
|
171
|
+
expect(resource?.attrs).toHaveProperty("classification");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Fix 4: entity types read from schema instead of hardcoded User/Resource
|
|
175
|
+
it("uses schema entity types (Endpoint not Resource) when defined in appliesTo", async () => {
|
|
176
|
+
const result = await handleGenerateSample({
|
|
177
|
+
policy: `permit(principal in Gateway::Role::"readonly", action in [Gateway::Action::"GET"], resource);`,
|
|
178
|
+
schema: GATEWAY_SCHEMA,
|
|
179
|
+
target_decision: "allow",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result.error).toBeUndefined();
|
|
183
|
+
expect(result.decision).toBe("Allow");
|
|
184
|
+
// Principal should be Gateway::User (from appliesTo.principalTypes), not Gateway::User (same here)
|
|
185
|
+
// Resource should be Gateway::Endpoint (from appliesTo.resourceTypes), not Gateway::Resource
|
|
186
|
+
expect(result.resource).toContain("Gateway::Endpoint");
|
|
187
|
+
expect(result.principal).toContain("Gateway::User");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Fix 5: in/contains conditions extracted and satisfied
|
|
191
|
+
it("extracts contains() conditions and satisfies them for allow", async () => {
|
|
192
|
+
const result = await handleGenerateSample({
|
|
193
|
+
policy: `permit(principal, action in [MyApp::Action::"READ"], resource) when { ["active", "pending"].contains(resource.status) };`,
|
|
194
|
+
schema: ABAC_SCHEMA,
|
|
195
|
+
target_decision: "allow",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(result.error).toBeUndefined();
|
|
199
|
+
expect(result.decision).toBe("Allow");
|
|
200
|
+
const resource = result.entities.find((e: { uid: { type: string } }) => e.uid.type?.includes("Resource"));
|
|
201
|
+
expect(["active", "pending"]).toContain(resource?.attrs?.status);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("extracts contains() conditions and violates them for deny", async () => {
|
|
205
|
+
const result = await handleGenerateSample({
|
|
206
|
+
policy: `permit(principal, action in [MyApp::Action::"READ"], resource) when { ["active", "pending"].contains(resource.status) };`,
|
|
207
|
+
schema: ABAC_SCHEMA,
|
|
208
|
+
target_decision: "deny",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(result.error).toBeUndefined();
|
|
212
|
+
expect(result.decision).toBe("Deny");
|
|
213
|
+
const resource = result.entities.find((e: { uid: { type: string } }) => e.uid.type?.includes("Resource"));
|
|
214
|
+
expect(["active", "pending"]).not.toContain(resource?.attrs?.status);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Path-matching cases (require like operator support)
|
|
218
|
+
// Schema: Gateway namespace with Endpoint entity having a path attribute
|
|
219
|
+
|
|
220
|
+
const GATEWAY_SCHEMA = JSON.stringify({
|
|
221
|
+
Gateway: {
|
|
222
|
+
entityTypes: {
|
|
223
|
+
User: {
|
|
224
|
+
memberOfTypes: ["Role"],
|
|
225
|
+
shape: { type: "Record", attributes: {} },
|
|
226
|
+
},
|
|
227
|
+
Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
228
|
+
Endpoint: {
|
|
229
|
+
memberOfTypes: [],
|
|
230
|
+
shape: {
|
|
231
|
+
type: "Record",
|
|
232
|
+
attributes: {
|
|
233
|
+
path: { type: "String", required: true },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
actions: {
|
|
239
|
+
GET: {
|
|
240
|
+
appliesTo: {
|
|
241
|
+
principalTypes: ["User"],
|
|
242
|
+
resourceTypes: ["Endpoint"],
|
|
243
|
+
context: { type: "Record", attributes: {} },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const PATH_POLICY = `permit (
|
|
251
|
+
principal in Gateway::Role::"readonly",
|
|
252
|
+
action in [Gateway::Action::"GET"],
|
|
253
|
+
resource
|
|
254
|
+
)
|
|
255
|
+
when {
|
|
256
|
+
resource.path == "/api/v1/policies"
|
|
257
|
+
|| (
|
|
258
|
+
resource.path like "/api/v1/policies/*"
|
|
259
|
+
&& !(resource.path like "/api/v1/policies/*/*")
|
|
260
|
+
)
|
|
261
|
+
};`;
|
|
262
|
+
|
|
263
|
+
it("5.6 — path-matching allow: generated path satisfies the policy", async () => {
|
|
264
|
+
const result = await handleGenerateSample({
|
|
265
|
+
policy: PATH_POLICY,
|
|
266
|
+
schema: GATEWAY_SCHEMA,
|
|
267
|
+
target_decision: "allow",
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(result.error).toBeUndefined();
|
|
271
|
+
expect(result.decision).toBe("Allow");
|
|
272
|
+
expect(result.ready_to_test).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("5.7 — path-matching deny: generated path violates depth limit", async () => {
|
|
276
|
+
const result = await handleGenerateSample({
|
|
277
|
+
policy: PATH_POLICY,
|
|
278
|
+
schema: GATEWAY_SCHEMA,
|
|
279
|
+
target_decision: "deny",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(result.error).toBeUndefined();
|
|
283
|
+
expect(result.decision).toBe("Deny");
|
|
284
|
+
expect(result.ready_to_test).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("picks an action whose appliesTo matches the scope's principal type, not just the first declared action", async () => {
|
|
288
|
+
// Regression for the v1 → v2 fix of defaultActionIdFromSchema. v1 returned
|
|
289
|
+
// Object.keys(actions)[0], which broke when the first action's
|
|
290
|
+
// appliesTo.principalTypes didn't include the policy's principal type.
|
|
291
|
+
//
|
|
292
|
+
// Failure case: schema with `adminOnly` declared FIRST (admins only) and
|
|
293
|
+
// `userRead` declared second (users only). A policy targeting a User would
|
|
294
|
+
// pick `adminOnly` under v1, schema validation rejects, generator outputs
|
|
295
|
+
// ready_to_test:false. Under v2 the generator picks `userRead`.
|
|
296
|
+
const schemaWithOrder = JSON.stringify({
|
|
297
|
+
Mismatch: {
|
|
298
|
+
entityTypes: {
|
|
299
|
+
User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
|
|
300
|
+
Admin: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
|
|
301
|
+
Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
302
|
+
},
|
|
303
|
+
actions: {
|
|
304
|
+
adminOnly: { appliesTo: { principalTypes: ["Admin"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } },
|
|
305
|
+
userRead: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
// Policy with NO action restriction — generator must default-pick an action.
|
|
310
|
+
// The principal is a User (per generator's principalType picked from the
|
|
311
|
+
// userRead action), so v2 should select `userRead`, not `adminOnly`.
|
|
312
|
+
const policy = `permit (principal, action, resource);`;
|
|
313
|
+
// Wait — extractScope picks principalType from the FIRST action's appliesTo
|
|
314
|
+
// when actionId is undefined (see entityTypesFromSchema fallback). That
|
|
315
|
+
// returns "Admin" (first action's principal type). So the generator would
|
|
316
|
+
// build a request as Admin + adminOnly. Both pieces agree but the v2 fix
|
|
317
|
+
// doesn't yet help because the principal type is also derived from the
|
|
318
|
+
// first action.
|
|
319
|
+
//
|
|
320
|
+
// To exercise the v2 fix specifically, use a policy that PINS the principal
|
|
321
|
+
// type (via `principal == User::"x"`) but leaves action unrestricted.
|
|
322
|
+
const pinnedPolicy = `permit (principal == Mismatch::User::"alice", action, resource);`;
|
|
323
|
+
|
|
324
|
+
const result = await handleGenerateSample({
|
|
325
|
+
policy: pinnedPolicy,
|
|
326
|
+
schema: schemaWithOrder,
|
|
327
|
+
target_decision: "allow",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(result.error).toBeUndefined();
|
|
331
|
+
// The generated action must match the User principal. adminOnly does NOT
|
|
332
|
+
// include User in its appliesTo; userRead does. v2 must pick userRead.
|
|
333
|
+
expect(result.action).toBe('Mismatch::Action::"userRead"');
|
|
334
|
+
void policy; // kept above as a written-out exploration; not used
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ─── kickoff-14 14b: double-namespace fix ──────────────────────────────────
|
|
338
|
+
|
|
339
|
+
it("kickoff-14 14b: cedarschema-text schema produces single-prefix principal/resource (no MyApp::MyApp::User)", async () => {
|
|
340
|
+
// The cwd-fallback path for cedar-sandbox supplies a .cedarschema text. The
|
|
341
|
+
// Cedar WASM `schemaToJsonWithResolvedTypes` emits already-namespaced type
|
|
342
|
+
// strings ("MyApp::User") for entries declared inside `namespace MyApp { ... }`.
|
|
343
|
+
// The generator used to wrap that in `${namespace}::${type}` again,
|
|
344
|
+
// producing `MyApp::MyApp::User::"sample-principal"`. Fix: skip re-prefixing
|
|
345
|
+
// when the type name already contains "::".
|
|
346
|
+
const cedarSchema = `namespace MyApp {
|
|
347
|
+
entity User { name: String };
|
|
348
|
+
entity Document { owner: String };
|
|
349
|
+
action "read" appliesTo { principal: User, resource: Document };
|
|
350
|
+
}`;
|
|
351
|
+
const result = await handleGenerateSample({
|
|
352
|
+
policy: `permit (principal, action, resource);`,
|
|
353
|
+
schema: cedarSchema,
|
|
354
|
+
target_decision: "allow",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(result.error).toBeUndefined();
|
|
358
|
+
expect(result.principal).toBe('MyApp::User::"sample-principal"');
|
|
359
|
+
expect(result.resource).toBe('MyApp::Document::"sample-resource"');
|
|
360
|
+
expect(result.action).toBe('MyApp::Action::"read"');
|
|
361
|
+
// Entity uids must use the same single-namespace form (not "MyApp::MyApp::User").
|
|
362
|
+
expect(result.entities.some((e) => e.uid.type === "MyApp::User" && e.uid.id === "sample-principal")).toBe(true);
|
|
363
|
+
expect(result.entities.some((e) => e.uid.type === "MyApp::Document" && e.uid.id === "sample-resource")).toBe(true);
|
|
364
|
+
expect(result.entities.every((e) => !e.uid.type.startsWith("MyApp::MyApp::"))).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("kickoff-14 14b: a different namespace name (OtherApp) also gets single-prefix output", async () => {
|
|
368
|
+
const cedarSchema = `namespace OtherApp {
|
|
369
|
+
entity User { name: String };
|
|
370
|
+
entity Document;
|
|
371
|
+
action "read" appliesTo { principal: User, resource: Document };
|
|
372
|
+
}`;
|
|
373
|
+
const result = await handleGenerateSample({
|
|
374
|
+
policy: `permit (principal, action, resource);`,
|
|
375
|
+
schema: cedarSchema,
|
|
376
|
+
target_decision: "allow",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
expect(result.error).toBeUndefined();
|
|
380
|
+
expect(result.principal).toBe('OtherApp::User::"sample-principal"');
|
|
381
|
+
expect(result.resource).toBe('OtherApp::Document::"sample-resource"');
|
|
382
|
+
expect(result.entities.every((e) => !e.uid.type.startsWith("OtherApp::OtherApp::"))).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("kickoff-14 14d F2: namespaceless JSON schema (empty-string namespace key) generates bare entity refs", async () => {
|
|
386
|
+
// Cedar's "no namespace" form is an empty-string key:
|
|
387
|
+
// `{"": {entityTypes: {...}}}`. The original `if (ns)` guard treated ""
|
|
388
|
+
// as falsy and silently fell back to `schemaNamespace = "MyApp"`, leaking
|
|
389
|
+
// a hallucinated namespace into the generated principal / resource /
|
|
390
|
+
// action references. The fix replaces the truthiness check with
|
|
391
|
+
// `ns !== undefined` so Cedar's legitimate empty namespace stays empty.
|
|
392
|
+
const namespacelessSchema = JSON.stringify({
|
|
393
|
+
"": {
|
|
394
|
+
entityTypes: {
|
|
395
|
+
User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
|
|
396
|
+
Document: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
397
|
+
},
|
|
398
|
+
actions: {
|
|
399
|
+
read: {
|
|
400
|
+
appliesTo: {
|
|
401
|
+
principalTypes: ["User"],
|
|
402
|
+
resourceTypes: ["Document"],
|
|
403
|
+
context: { type: "Record", attributes: {} },
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
const result = await handleGenerateSample({
|
|
410
|
+
policy: `permit (principal, action, resource);`,
|
|
411
|
+
schema: namespacelessSchema,
|
|
412
|
+
target_decision: "allow",
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
expect(result.error).toBeUndefined();
|
|
416
|
+
// Bare refs — no namespace prefix at all (not "MyApp::..." and not "::User").
|
|
417
|
+
expect(result.principal).toBe('User::"sample-principal"');
|
|
418
|
+
expect(result.resource).toBe('Document::"sample-resource"');
|
|
419
|
+
expect(result.action).toBe('Action::"read"');
|
|
420
|
+
expect(result.entities.every((e) => !e.uid.type.includes("::"))).toBe(true);
|
|
421
|
+
expect(result.ready_to_test).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("kickoff-14 14d F3: ready_to_test is false (and explanation flags the mismatch) when the generator's output doesn't satisfy the user's schema", async () => {
|
|
425
|
+
// F3 surfaced that the internal isAuthorized verification call did not
|
|
426
|
+
// include the user's schema, so a payload referencing entity types the
|
|
427
|
+
// schema doesn't declare still got `ready_to_test: true`. Fix: pass schema
|
|
428
|
+
// with validateRequest:true. This test pins that the verification now
|
|
429
|
+
// reflects schema reality by feeding a policy that pins a principal type
|
|
430
|
+
// the schema does not declare (`Ghost::User`).
|
|
431
|
+
const schema = JSON.stringify({
|
|
432
|
+
Real: {
|
|
433
|
+
entityTypes: {
|
|
434
|
+
User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
|
|
435
|
+
Document: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
436
|
+
},
|
|
437
|
+
actions: {
|
|
438
|
+
read: {
|
|
439
|
+
appliesTo: { principalTypes: ["User"], resourceTypes: ["Document"], context: { type: "Record", attributes: {} } },
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
// The policy pins a principal type from a namespace the schema doesn't declare.
|
|
445
|
+
const policy = `permit (principal == Ghost::User::"alice", action, resource);`;
|
|
446
|
+
const result = await handleGenerateSample({
|
|
447
|
+
policy,
|
|
448
|
+
schema,
|
|
449
|
+
target_decision: "allow",
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// The tool returns gracefully (no thrown exception) but signals the mismatch.
|
|
453
|
+
// Either via `error` (schema-validation failure path) or via
|
|
454
|
+
// `ready_to_test: false` with explanation. The contract: the response must
|
|
455
|
+
// NOT claim ready_to_test:true on a payload Cedar would reject under the
|
|
456
|
+
// user's schema.
|
|
457
|
+
if (result.ready_to_test === true) {
|
|
458
|
+
throw new Error(`ready_to_test was incorrectly true. Result: ${JSON.stringify(result, null, 2)}`);
|
|
459
|
+
}
|
|
460
|
+
// The verification path catches the schema-mismatched principal type.
|
|
461
|
+
expect(result.error ?? result.explanation).toMatch(/schema|principal|entity/i);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("kickoff-14 14b: JSON-format schema (already bare types) keeps single namespace", async () => {
|
|
465
|
+
// Regression: the JSON schema path supplies bare entity-type names ("User",
|
|
466
|
+
// "Document"), so qualifyEntityType prefixes with the namespace. Existing
|
|
467
|
+
// tests already exercise this path; this assertion just pins that the fix
|
|
468
|
+
// didn't accidentally break it.
|
|
469
|
+
const result = await handleGenerateSample({
|
|
470
|
+
policy: `permit (principal, action, resource);`,
|
|
471
|
+
schema: ABAC_SCHEMA,
|
|
472
|
+
target_decision: "allow",
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
expect(result.error).toBeUndefined();
|
|
476
|
+
expect(result.principal).toBe('MyApp::User::"sample-principal"');
|
|
477
|
+
expect(result.resource).toBe('MyApp::Resource::"sample-resource"');
|
|
478
|
+
expect(result.entities.every((e) => !e.uid.type.startsWith("MyApp::MyApp::"))).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { handleLinkTemplate } from "../../src/tools/link-template.js";
|
|
3
|
+
|
|
4
|
+
const SCHEMA = `namespace App {
|
|
5
|
+
entity User;
|
|
6
|
+
entity Document;
|
|
7
|
+
action read appliesTo { principal: [User], resource: [Document], context: {} };
|
|
8
|
+
}`;
|
|
9
|
+
|
|
10
|
+
const BOTH_SLOTS_TEMPLATE = `permit(
|
|
11
|
+
principal == ?principal,
|
|
12
|
+
action == App::Action::"read",
|
|
13
|
+
resource == ?resource
|
|
14
|
+
);`;
|
|
15
|
+
|
|
16
|
+
const RESOURCE_ONLY_TEMPLATE = `permit(
|
|
17
|
+
principal,
|
|
18
|
+
action == App::Action::"read",
|
|
19
|
+
resource == ?resource
|
|
20
|
+
);`;
|
|
21
|
+
|
|
22
|
+
describe("cedar_link_template", () => {
|
|
23
|
+
it("LT1 — links both slots to produce a valid Cedar policy", async () => {
|
|
24
|
+
const result = await handleLinkTemplate({
|
|
25
|
+
template: BOTH_SLOTS_TEMPLATE,
|
|
26
|
+
principal: 'App::User::"alice"',
|
|
27
|
+
resource: 'App::Document::"doc-42"',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(result.error).toBeUndefined();
|
|
31
|
+
expect(result.linked_policy).toContain('App::User::"alice"');
|
|
32
|
+
expect(result.linked_policy).toContain('App::Document::"doc-42"');
|
|
33
|
+
expect(result.slots_bound).toHaveProperty("?principal");
|
|
34
|
+
expect(result.slots_bound).toHaveProperty("?resource");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("LT2 — linked policy validates against schema when schema provided", async () => {
|
|
38
|
+
const result = await handleLinkTemplate({
|
|
39
|
+
template: BOTH_SLOTS_TEMPLATE,
|
|
40
|
+
principal: 'App::User::"alice"',
|
|
41
|
+
resource: 'App::Document::"doc-42"',
|
|
42
|
+
schema: SCHEMA,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.valid).toBe(true);
|
|
46
|
+
expect(result.errors).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("LT3 — missing required slot returns error", async () => {
|
|
50
|
+
const result = await handleLinkTemplate({
|
|
51
|
+
template: BOTH_SLOTS_TEMPLATE,
|
|
52
|
+
// ?principal provided but ?resource missing
|
|
53
|
+
principal: 'App::User::"alice"',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.error).toBeDefined();
|
|
57
|
+
expect(result.error).toMatch(/\?resource/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("LT4 — resource-only template links with only resource slot", async () => {
|
|
61
|
+
const result = await handleLinkTemplate({
|
|
62
|
+
template: RESOURCE_ONLY_TEMPLATE,
|
|
63
|
+
resource: 'App::Document::"doc-99"',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.error).toBeUndefined();
|
|
67
|
+
expect(result.linked_policy).toContain('App::Document::"doc-99"');
|
|
68
|
+
expect(result.slots_bound).not.toHaveProperty("?principal");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("LT5 — invalid template text returns error", async () => {
|
|
72
|
+
const result = await handleLinkTemplate({
|
|
73
|
+
template: "not valid cedar",
|
|
74
|
+
principal: 'App::User::"alice"',
|
|
75
|
+
resource: 'App::Document::"doc-1"',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(result.error).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("LT6 — invalid entity ref format returns error", async () => {
|
|
82
|
+
const result = await handleLinkTemplate({
|
|
83
|
+
template: BOTH_SLOTS_TEMPLATE,
|
|
84
|
+
principal: "not-an-entity-ref",
|
|
85
|
+
resource: 'App::Document::"doc-1"',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.error).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
});
|