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,291 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { handleDiffStores } from "../../src/tools/diff-stores.js";
|
|
6
|
+
import { StoreManager } from "../../src/resources/store-manager.js";
|
|
7
|
+
|
|
8
|
+
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const SCHEMA_TEXT = `namespace DocMgmt {
|
|
11
|
+
entity User in [Role] = { name: String };
|
|
12
|
+
entity Role;
|
|
13
|
+
entity Document = { classification: String };
|
|
14
|
+
action READ appliesTo { principal: [User], resource: [Document], context: {} };
|
|
15
|
+
action WRITE appliesTo { principal: [User], resource: [Document], context: {} };
|
|
16
|
+
action DELETE appliesTo { principal: [User], resource: [Document], context: {} };
|
|
17
|
+
}`;
|
|
18
|
+
|
|
19
|
+
function makeStore(
|
|
20
|
+
baseDir: string,
|
|
21
|
+
name: string,
|
|
22
|
+
policies: Record<string, string>,
|
|
23
|
+
schema = SCHEMA_TEXT
|
|
24
|
+
): string {
|
|
25
|
+
const path = join(baseDir, name);
|
|
26
|
+
mkdirSync(join(path, "policies"), { recursive: true });
|
|
27
|
+
for (const [id, content] of Object.entries(policies)) {
|
|
28
|
+
writeFileSync(join(path, "policies", `${id}.cedar`), content);
|
|
29
|
+
}
|
|
30
|
+
writeFileSync(join(path, "schema.cedarschema"), schema);
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ADMIN_POLICY = `permit(principal in DocMgmt::Role::"admin", action, resource);`;
|
|
35
|
+
const EDITOR_POLICY_V1 = `permit(principal in DocMgmt::Role::"editor", action in [DocMgmt::Action::"READ", DocMgmt::Action::"WRITE"], resource);`;
|
|
36
|
+
const EDITOR_POLICY_V2_CONDITION_CHANGE = `permit(principal in DocMgmt::Role::"editor", action in [DocMgmt::Action::"READ", DocMgmt::Action::"WRITE"], resource) when { resource.classification != "top_secret" };`;
|
|
37
|
+
const EDITOR_POLICY_V3_PRINCIPAL_CHANGE = `permit(principal in DocMgmt::Role::"senior_editor", action in [DocMgmt::Action::"READ", DocMgmt::Action::"WRITE"], resource);`;
|
|
38
|
+
const VIEWER_POLICY = `permit(principal in DocMgmt::Role::"viewer", action == DocMgmt::Action::"READ", resource);`;
|
|
39
|
+
const FORBID_POLICY = `forbid(principal, action, resource) when { resource.classification == "top_secret" };`;
|
|
40
|
+
|
|
41
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe("cedar_diff_policy_stores", () => {
|
|
44
|
+
let tmpDir: string;
|
|
45
|
+
let manager: StoreManager;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
tmpDir = join(tmpdir(), `cedar-diff-test-${Date.now()}`);
|
|
49
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
50
|
+
manager = new StoreManager();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reports no changes when blue and green are identical", async () => {
|
|
58
|
+
const policies = { admin: ADMIN_POLICY, editor: EDITOR_POLICY_V1 };
|
|
59
|
+
const bluePath = makeStore(tmpDir, "blue", policies);
|
|
60
|
+
const greenPath = makeStore(tmpDir, "green", policies);
|
|
61
|
+
manager.loadFromRoots([
|
|
62
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
63
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
67
|
+
expect(result.error).toBeUndefined();
|
|
68
|
+
expect(result.policies_added).toHaveLength(0);
|
|
69
|
+
expect(result.policies_removed).toHaveLength(0);
|
|
70
|
+
expect(result.policies_modified).toHaveLength(0);
|
|
71
|
+
expect(result.schema_diff.risk_level).toBe("safe");
|
|
72
|
+
expect(result.schema_diff.entity_types.modified).toHaveLength(0);
|
|
73
|
+
expect(result.summary).toMatch(/no changes/i);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("detects a newly added policy in green", async () => {
|
|
77
|
+
const bluePath = makeStore(tmpDir, "blue", { admin: ADMIN_POLICY });
|
|
78
|
+
const greenPath = makeStore(tmpDir, "green", { admin: ADMIN_POLICY, viewer: VIEWER_POLICY });
|
|
79
|
+
manager.loadFromRoots([
|
|
80
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
81
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
85
|
+
expect(result.policies_added).toHaveLength(1);
|
|
86
|
+
expect(result.policies_added[0]!.policy_id).toBe("viewer");
|
|
87
|
+
expect(result.policies_removed).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("detects a removed policy (present in blue, absent in green)", async () => {
|
|
91
|
+
const bluePath = makeStore(tmpDir, "blue", { admin: ADMIN_POLICY, viewer: VIEWER_POLICY });
|
|
92
|
+
const greenPath = makeStore(tmpDir, "green", { admin: ADMIN_POLICY });
|
|
93
|
+
manager.loadFromRoots([
|
|
94
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
95
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
99
|
+
expect(result.policies_removed).toHaveLength(1);
|
|
100
|
+
expect(result.policies_removed[0]!.policy_id).toBe("viewer");
|
|
101
|
+
expect(result.policies_added).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("detects a condition change and classifies it as in-place OK", async () => {
|
|
105
|
+
const bluePath = makeStore(tmpDir, "blue", { editor: EDITOR_POLICY_V1 });
|
|
106
|
+
const greenPath = makeStore(tmpDir, "green", { editor: EDITOR_POLICY_V2_CONDITION_CHANGE });
|
|
107
|
+
manager.loadFromRoots([
|
|
108
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
109
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
113
|
+
expect(result.policies_modified).toHaveLength(1);
|
|
114
|
+
const mod = result.policies_modified[0]!;
|
|
115
|
+
expect(mod.policy_id).toBe("editor");
|
|
116
|
+
expect(mod.can_update_in_place).toBe(true);
|
|
117
|
+
expect(mod.changes.some((c) => c.field === "conditions")).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("detects a principal change and classifies it as requires recreate", async () => {
|
|
121
|
+
const bluePath = makeStore(tmpDir, "blue", { editor: EDITOR_POLICY_V1 });
|
|
122
|
+
const greenPath = makeStore(tmpDir, "green", { editor: EDITOR_POLICY_V3_PRINCIPAL_CHANGE });
|
|
123
|
+
manager.loadFromRoots([
|
|
124
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
125
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
129
|
+
expect(result.policies_modified).toHaveLength(1);
|
|
130
|
+
const mod = result.policies_modified[0]!;
|
|
131
|
+
expect(mod.can_update_in_place).toBe(false);
|
|
132
|
+
expect(mod.changes.some((c) => c.field === "principal" && !c.in_place_allowed)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("handles multiple simultaneous changes correctly", async () => {
|
|
136
|
+
const bluePath = makeStore(tmpDir, "blue", {
|
|
137
|
+
admin: ADMIN_POLICY,
|
|
138
|
+
editor: EDITOR_POLICY_V1,
|
|
139
|
+
viewer: VIEWER_POLICY,
|
|
140
|
+
});
|
|
141
|
+
const greenPath = makeStore(tmpDir, "green", {
|
|
142
|
+
admin: ADMIN_POLICY, // unchanged
|
|
143
|
+
editor: EDITOR_POLICY_V3_PRINCIPAL_CHANGE, // principal change (recreate)
|
|
144
|
+
forbid: FORBID_POLICY, // added
|
|
145
|
+
// viewer removed
|
|
146
|
+
});
|
|
147
|
+
manager.loadFromRoots([
|
|
148
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
149
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
153
|
+
expect(result.policies_added).toHaveLength(1);
|
|
154
|
+
expect(result.policies_added[0]!.policy_id).toBe("forbid");
|
|
155
|
+
expect(result.policies_removed).toHaveLength(1);
|
|
156
|
+
expect(result.policies_removed[0]!.policy_id).toBe("viewer");
|
|
157
|
+
expect(result.policies_modified).toHaveLength(1);
|
|
158
|
+
expect(result.policies_modified[0]!.can_update_in_place).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("detects schema changes", async () => {
|
|
162
|
+
const altSchema = SCHEMA_TEXT.replace("name: String", "name: String, email: String");
|
|
163
|
+
const bluePath = makeStore(tmpDir, "blue", { admin: ADMIN_POLICY }, SCHEMA_TEXT);
|
|
164
|
+
const greenPath = makeStore(tmpDir, "green", { admin: ADMIN_POLICY }, altSchema);
|
|
165
|
+
manager.loadFromRoots([
|
|
166
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
167
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
171
|
+
|
|
172
|
+
// Structured schema_diff: User.email added as required attribute → breaking
|
|
173
|
+
const userMod = result.schema_diff.entity_types.modified.find((m) => m.name === "User");
|
|
174
|
+
expect(userMod).toBeDefined();
|
|
175
|
+
const emailChange = userMod!.attribute_changes?.find((c) => c.attr === "email");
|
|
176
|
+
expect(emailChange).toBeDefined();
|
|
177
|
+
expect(emailChange!.change).toBe("added");
|
|
178
|
+
expect(emailChange!.risk).toBe("breaking");
|
|
179
|
+
expect(result.schema_diff.risk_level).toBe("breaking");
|
|
180
|
+
expect(result.summary).toMatch(/schema changed.*BREAKING/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("schema_diff is populated with empty diff when schemas match", async () => {
|
|
184
|
+
const bluePath = makeStore(tmpDir, "blue", { admin: ADMIN_POLICY }, SCHEMA_TEXT);
|
|
185
|
+
const greenPath = makeStore(tmpDir, "green", { admin: ADMIN_POLICY }, SCHEMA_TEXT);
|
|
186
|
+
manager.loadFromRoots([
|
|
187
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
188
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
192
|
+
|
|
193
|
+
expect(result.schema_diff.entity_types.added).toHaveLength(0);
|
|
194
|
+
expect(result.schema_diff.entity_types.removed).toHaveLength(0);
|
|
195
|
+
expect(result.schema_diff.entity_types.modified).toHaveLength(0);
|
|
196
|
+
expect(result.schema_diff.actions.added).toHaveLength(0);
|
|
197
|
+
expect(result.schema_diff.actions.removed).toHaveLength(0);
|
|
198
|
+
expect(result.schema_diff.risk_level).toBe("safe");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("schema_diff surfaces action removal as breaking", async () => {
|
|
202
|
+
// Strip the WRITE and DELETE action declarations — each is its own statement ending in `};`
|
|
203
|
+
const altSchema = SCHEMA_TEXT
|
|
204
|
+
.replace("action WRITE appliesTo { principal: [User], resource: [Document], context: {} };", "")
|
|
205
|
+
.replace("action DELETE appliesTo { principal: [User], resource: [Document], context: {} };", "");
|
|
206
|
+
const bluePath = makeStore(tmpDir, "blue", { admin: ADMIN_POLICY }, SCHEMA_TEXT);
|
|
207
|
+
const greenPath = makeStore(tmpDir, "green", { admin: ADMIN_POLICY }, altSchema);
|
|
208
|
+
manager.loadFromRoots([
|
|
209
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
210
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
const result = await handleDiffStores({ blue: "blue", green: "green" }, manager);
|
|
214
|
+
|
|
215
|
+
expect(result.schema_diff.actions.removed.length).toBeGreaterThanOrEqual(2);
|
|
216
|
+
expect(result.schema_diff.risk_level).toBe("breaking");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("behavioral diff — reports invalid requests instead of silently skipping", async () => {
|
|
220
|
+
const bluePath = makeStore(tmpDir, "blue", { admin: ADMIN_POLICY });
|
|
221
|
+
const greenPath = makeStore(tmpDir, "green", { admin: ADMIN_POLICY });
|
|
222
|
+
manager.loadFromRoots([
|
|
223
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
224
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
const result = await handleDiffStores({
|
|
228
|
+
blue: "blue",
|
|
229
|
+
green: "green",
|
|
230
|
+
behavioral_test_requests: JSON.stringify([
|
|
231
|
+
{
|
|
232
|
+
principal: "bad-format-no-colons", // invalid
|
|
233
|
+
action: 'DocMgmt::Action::"READ"',
|
|
234
|
+
resource: 'DocMgmt::Document::"d1"',
|
|
235
|
+
entities: "[]",
|
|
236
|
+
},
|
|
237
|
+
]),
|
|
238
|
+
}, manager);
|
|
239
|
+
|
|
240
|
+
// Invalid requests should appear in behavioral_diff with drifted:false and an error note
|
|
241
|
+
// NOT silently disappear from the results
|
|
242
|
+
expect(result.behavioral_diff).toBeDefined();
|
|
243
|
+
expect(result.behavioral_diff!.length).toBe(1);
|
|
244
|
+
expect(result.behavioral_diff![0]!.drifted).toBe(false);
|
|
245
|
+
expect(result.behavioral_diff![0]!.blue_decision).toBe("Error");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns error for unknown store name", async () => {
|
|
249
|
+
const bluePath = makeStore(tmpDir, "blue", { admin: ADMIN_POLICY });
|
|
250
|
+
manager.loadFromRoots([{ uri: `file://${bluePath}`, name: "blue" }]);
|
|
251
|
+
|
|
252
|
+
const result = await handleDiffStores({ blue: "blue", green: "ghost" }, manager);
|
|
253
|
+
expect(result.error).toBeDefined();
|
|
254
|
+
expect(result.error).toMatch(/ghost/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("behavioral diff — detects decision drift between blue and green", async () => {
|
|
258
|
+
// Blue: editor can write; Green: editor can only read (principal change)
|
|
259
|
+
const bluePath = makeStore(tmpDir, "blue", { editor: EDITOR_POLICY_V1 });
|
|
260
|
+
const greenPath = makeStore(tmpDir, "green", { editor: VIEWER_POLICY }); // viewer policy = READ only
|
|
261
|
+
manager.loadFromRoots([
|
|
262
|
+
{ uri: `file://${bluePath}`, name: "blue" },
|
|
263
|
+
{ uri: `file://${greenPath}`, name: "green" },
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
const behavioralTests = JSON.stringify([
|
|
267
|
+
{
|
|
268
|
+
principal: 'DocMgmt::User::"bob"',
|
|
269
|
+
action: 'DocMgmt::Action::"WRITE"',
|
|
270
|
+
resource: 'DocMgmt::Document::"doc-1"',
|
|
271
|
+
entities: JSON.stringify([
|
|
272
|
+
{ uid: { type: "DocMgmt::User", id: "bob" }, attrs: { name: "Bob" }, parents: [{ type: "DocMgmt::Role", id: "editor" }] },
|
|
273
|
+
{ uid: { type: "DocMgmt::Role", id: "editor" }, attrs: {}, parents: [] },
|
|
274
|
+
{ uid: { type: "DocMgmt::Document", id: "doc-1" }, attrs: { classification: "public" }, parents: [] },
|
|
275
|
+
]),
|
|
276
|
+
},
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
const result = await handleDiffStores(
|
|
280
|
+
{ blue: "blue", green: "green", behavioral_test_requests: behavioralTests },
|
|
281
|
+
manager
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
expect(result.behavioral_diff).toBeDefined();
|
|
285
|
+
expect(result.behavioral_diff!.length).toBeGreaterThan(0);
|
|
286
|
+
const drift = result.behavioral_diff![0]!;
|
|
287
|
+
expect(drift.blue_decision).toBe("Allow");
|
|
288
|
+
expect(drift.green_decision).toBe("Deny");
|
|
289
|
+
expect(drift.drifted).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
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 { handleExplain, handleExplainMany, handleExplainMcp } from "../../src/tools/explain.js";
|
|
6
|
+
import { storeManager } from "../../src/resources/store-manager.js";
|
|
7
|
+
|
|
8
|
+
const noResolve = (uri: string): { content: string } | { error: string } => ({
|
|
9
|
+
error: `unexpected resolveRef call in auto-discovery test: ${uri}`,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Dataset 7 test cases — generic attribute names only.
|
|
13
|
+
|
|
14
|
+
describe("cedar_explain", () => {
|
|
15
|
+
it("7.1 — simple RBAC: permit, role membership, unrestricted action and resource", async () => {
|
|
16
|
+
const result = await handleExplain({
|
|
17
|
+
policy: `permit(
|
|
18
|
+
principal in MyApp::Role::"admin",
|
|
19
|
+
action,
|
|
20
|
+
resource
|
|
21
|
+
);`,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(result.effect).toBe("permit");
|
|
25
|
+
expect(result.principal.description).toContain("admin");
|
|
26
|
+
expect(result.action.description).toContain("any action");
|
|
27
|
+
expect(result.resource.description).toContain("any resource");
|
|
28
|
+
expect(result.conditions).toHaveLength(0);
|
|
29
|
+
expect(result.summary).toMatch(/PERMITS/i);
|
|
30
|
+
expect(result.patterns_detected).toContain("role_based_access");
|
|
31
|
+
expect(result.patterns_detected).toContain("unrestricted_action");
|
|
32
|
+
expect(result.patterns_detected).toContain("unrestricted_resource");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("7.2 — forbid with unless: attribute condition + role exemption", async () => {
|
|
36
|
+
const result = await handleExplain({
|
|
37
|
+
policy: `forbid (principal, action, resource)
|
|
38
|
+
when { resource.classification == "top_secret" }
|
|
39
|
+
unless { principal in DocMgmt::Role::"admin" };`,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result.effect).toBe("forbid");
|
|
43
|
+
expect(result.principal.description).toContain("any principal");
|
|
44
|
+
expect(result.conditions).toHaveLength(2);
|
|
45
|
+
expect(result.conditions[0]!.kind).toBe("when");
|
|
46
|
+
expect(result.conditions[1]!.kind).toBe("unless");
|
|
47
|
+
expect(result.summary).toMatch(/FORBIDS/i);
|
|
48
|
+
expect(result.patterns_detected).toContain("forbid_policy");
|
|
49
|
+
expect(result.patterns_detected).toContain("role_exemption");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("7.3 — ABAC with optional attribute guard", async () => {
|
|
53
|
+
const result = await handleExplain({
|
|
54
|
+
policy: `permit (principal, action in [DocMgmt::Action::"READ"], resource)
|
|
55
|
+
when {
|
|
56
|
+
principal.name == "service_x" &&
|
|
57
|
+
resource has tag &&
|
|
58
|
+
resource.tag == "confidential"
|
|
59
|
+
};`,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
expect(result.effect).toBe("permit");
|
|
64
|
+
expect(result.principal.description).toContain("any principal");
|
|
65
|
+
expect(result.action.description).toContain("READ");
|
|
66
|
+
expect(result.conditions.length).toBeGreaterThan(0);
|
|
67
|
+
expect(result.conditions[0]!.text).toContain("AND");
|
|
68
|
+
expect(result.patterns_detected).toContain("optional_attribute_guard");
|
|
69
|
+
expect(result.patterns_detected).toContain("name_based_identity");
|
|
70
|
+
expect(result.summary).toMatch(/PERMITS/i);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("7.3b — path-matching policy: like conditions render as Cedar syntax not 'complex condition'", async () => {
|
|
74
|
+
const result = await handleExplain({
|
|
75
|
+
policy: `permit (
|
|
76
|
+
principal in DocMgmt::Role::"readonly",
|
|
77
|
+
action in [DocMgmt::Action::"GET"],
|
|
78
|
+
resource
|
|
79
|
+
)
|
|
80
|
+
when {
|
|
81
|
+
resource.path like "/api/v1/policies/*"
|
|
82
|
+
&& !(resource.path like "/api/v1/policies/*/*")
|
|
83
|
+
};`,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.effect).toBe("permit");
|
|
87
|
+
expect(result.conditions.length).toBeGreaterThan(0);
|
|
88
|
+
// Must render the like pattern, not fall back to "complex condition"
|
|
89
|
+
expect(result.conditions[0]!.text).toContain("like");
|
|
90
|
+
expect(result.conditions[0]!.text).toContain("/api/v1/policies/");
|
|
91
|
+
expect(result.conditions[0]!.text).not.toBe("WHEN complex condition");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("7.4 — template policy with ?principal and ?resource slots", async () => {
|
|
95
|
+
const result = await handleExplain({
|
|
96
|
+
policy: `permit(
|
|
97
|
+
principal in ?principal,
|
|
98
|
+
action == MyApp::Action::"GET",
|
|
99
|
+
resource == ?resource
|
|
100
|
+
);`,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.effect).toBe("permit");
|
|
104
|
+
expect(result.principal.description).toContain("?principal");
|
|
105
|
+
expect(result.resource.description).toContain("?resource");
|
|
106
|
+
expect(result.patterns_detected).toContain("template_policy");
|
|
107
|
+
expect(result.patterns_detected).toContain("slot_resource");
|
|
108
|
+
expect(result.summary).toMatch(/TEMPLATE/i);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("cedar_explain — multi-policy (handleExplainMany)", () => {
|
|
113
|
+
const POLICY_SET = `
|
|
114
|
+
permit (
|
|
115
|
+
principal in DocMgmt::Role::"admin",
|
|
116
|
+
action,
|
|
117
|
+
resource
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
permit (
|
|
121
|
+
principal in DocMgmt::Role::"editor",
|
|
122
|
+
action in [DocMgmt::Action::"READ", DocMgmt::Action::"WRITE"],
|
|
123
|
+
resource
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
forbid (
|
|
127
|
+
principal,
|
|
128
|
+
action,
|
|
129
|
+
resource
|
|
130
|
+
)
|
|
131
|
+
when {
|
|
132
|
+
resource.classification == "top_secret"
|
|
133
|
+
}
|
|
134
|
+
unless {
|
|
135
|
+
principal in DocMgmt::Role::"admin"
|
|
136
|
+
};
|
|
137
|
+
`.trim();
|
|
138
|
+
|
|
139
|
+
it("returns ExplainManyResult with policy_count for a policy set", async () => {
|
|
140
|
+
const result = await handleExplainMany({ policy: POLICY_SET });
|
|
141
|
+
expect("policy_count" in result).toBe(true);
|
|
142
|
+
if ("policy_count" in result) {
|
|
143
|
+
expect(result.policy_count).toBe(3);
|
|
144
|
+
expect(result.policies).toHaveLength(3);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("each policy in the set has its own summary and effect", async () => {
|
|
149
|
+
const result = await handleExplainMany({ policy: POLICY_SET });
|
|
150
|
+
if ("policy_count" in result) {
|
|
151
|
+
expect(result.policies[0]!.effect).toBe("permit");
|
|
152
|
+
expect(result.policies[2]!.effect).toBe("forbid");
|
|
153
|
+
expect(result.policies[0]!.summary).toMatch(/PERMITS/i);
|
|
154
|
+
expect(result.policies[2]!.summary).toMatch(/FORBIDS/i);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("falls back to single ExplainResult for a single policy", async () => {
|
|
159
|
+
const result = await handleExplainMany({
|
|
160
|
+
policy: `permit(principal in DocMgmt::Role::"admin", action, resource);`,
|
|
161
|
+
});
|
|
162
|
+
// Single policy returns ExplainResult directly (no policy_count wrapping)
|
|
163
|
+
expect("effect" in result).toBe(true);
|
|
164
|
+
expect("policy_count" in result).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("each policy has an index", async () => {
|
|
168
|
+
const result = await handleExplainMany({ policy: POLICY_SET });
|
|
169
|
+
if ("policy_count" in result) {
|
|
170
|
+
expect(result.policies[0]!.index).toBe(0);
|
|
171
|
+
expect(result.policies[1]!.index).toBe(1);
|
|
172
|
+
expect(result.policies[2]!.index).toBe(2);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("cedar_explain — 10d auto-discovery", () => {
|
|
178
|
+
const tempDirs: string[] = [];
|
|
179
|
+
const SCHEMA_TEXT = `namespace DocMgmt {
|
|
180
|
+
entity User in [Role];
|
|
181
|
+
entity Role;
|
|
182
|
+
entity Document = { classification: String };
|
|
183
|
+
action READ appliesTo { principal: [User], resource: [Document], context: {} };
|
|
184
|
+
}`;
|
|
185
|
+
|
|
186
|
+
function makeWorkspace(name: string): string {
|
|
187
|
+
const dir = mkdtempSync(join(tmpdir(), `cedar-explain-auto-${name}-`));
|
|
188
|
+
mkdirSync(join(dir, "policies"), { recursive: true });
|
|
189
|
+
writeFileSync(join(dir, "schema.cedarschema"), SCHEMA_TEXT);
|
|
190
|
+
return dir;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
afterEach(() => {
|
|
194
|
+
storeManager.loadFromRoots([]);
|
|
195
|
+
while (tempDirs.length > 0) {
|
|
196
|
+
const dir = tempDirs.pop()!;
|
|
197
|
+
rmSync(dir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("single store loaded auto-pulls the schema and surfaces auto_discovered.schema_from", async () => {
|
|
202
|
+
const ws = makeWorkspace("single");
|
|
203
|
+
tempDirs.push(ws);
|
|
204
|
+
storeManager.loadFromRoots([{ uri: `file://${ws}`, name: "workspace" }]);
|
|
205
|
+
|
|
206
|
+
const outcome = await handleExplainMcp(
|
|
207
|
+
{
|
|
208
|
+
policy: `permit (principal in DocMgmt::Role::"admin", action == DocMgmt::Action::"READ", resource);`,
|
|
209
|
+
},
|
|
210
|
+
noResolve,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect("error" in outcome).toBe(false);
|
|
214
|
+
if ("error" in outcome) return;
|
|
215
|
+
// Single-policy input returns ExplainResult directly. Either shape carries
|
|
216
|
+
// auto_discovered when discovery fired, so assert on it without narrowing.
|
|
217
|
+
expect((outcome.result as { auto_discovered?: { schema_from?: string } }).auto_discovered).toEqual({
|
|
218
|
+
schema_from: "workspace",
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { handleFormat } from "../../src/tools/format.js";
|
|
3
|
+
|
|
4
|
+
describe("cedar_format", () => {
|
|
5
|
+
it("formats a compact policy to canonical style", async () => {
|
|
6
|
+
const result = await handleFormat({
|
|
7
|
+
policies: `permit(principal in DocMgmt::Role::"admin",action,resource);`,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
expect(result.formatted).toContain("permit (");
|
|
11
|
+
expect(result.formatted).toContain('principal in DocMgmt::Role::"admin"');
|
|
12
|
+
expect(result.error).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("formats multiple policies", async () => {
|
|
16
|
+
const result = await handleFormat({
|
|
17
|
+
policies: `permit(principal,action,resource);forbid(principal,action,resource)when{resource.sensitive==true};`,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(result.formatted).toContain("permit (");
|
|
21
|
+
expect(result.formatted).toContain("forbid (");
|
|
22
|
+
expect(result.error).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns error for syntactically invalid input", async () => {
|
|
26
|
+
const result = await handleFormat({
|
|
27
|
+
policies: `this is not cedar`,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(result.error).not.toBeNull();
|
|
31
|
+
expect(result.formatted).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|