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