diff --git a/CedarWasm/.gitignore b/CedarWasm/.gitignore new file mode 100644 index 00000000..d03db9c5 --- /dev/null +++ b/CedarWasm/.gitignore @@ -0,0 +1,3 @@ +wasm-build/target/ +core/target/ +benchmark/target/ diff --git a/CedarWasm/README.md b/CedarWasm/README.md new file mode 100644 index 00000000..f7414243 --- /dev/null +++ b/CedarWasm/README.md @@ -0,0 +1,77 @@ +# Cedar Wasm + +Pure-Java Cedar policy engine using WebAssembly. The Cedar Rust crate is compiled to Wasm and executed via [Chicory Redline](https://github.com/AnyTimeTraveler/chicory-redline) — no JNI or native libraries required. + +## Project structure + +``` +CedarWasm/ +├── wasm-build/ Rust crate compiled to wasm32-unknown-unknown +├── core/ Java module (CedarEngine + Chicory Redline) +└── benchmark/ JMH benchmarks comparing JNI vs Wasm +``` + +## Prerequisites + +- Java 17+ +- Rust 1.89+ with `wasm32-unknown-unknown` target +- `wasm-opt` (from [binaryen](https://github.com/WebAssembly/binaryen)) +- [Chicory Redline](https://github.com/AnyTimeTraveler/chicory-redline) installed to local Maven repo (`mvn install -DskipTests`) + +## Building the Wasm module + +```bash +cd wasm-build +rustup target add wasm32-unknown-unknown +cargo build --release --target wasm32-unknown-unknown +wasm-opt --enable-bulk-memory -O3 \ + target/wasm32-unknown-unknown/release/cedar_wasm.wasm \ + -o ../core/wasm/cedar_wasm.wasm +``` + +## Building and testing + +```bash +cd CedarWasm +mvn clean test -pl core +``` + +## Running benchmarks (JNI vs Wasm) + +First, build the JNI uber jar (requires Gradle + Rust): + +```bash +cd CedarJava +./gradlew uberJar -x test -x spotbugsMain -x spotbugsTest -x spotbugsJmh \ + -x checkstyleMain -x checkstyleTest -x jacocoTestCoverageVerification +``` + +Then run the benchmarks: + +```bash +cd CedarWasm +mvn install -DskipTests +mvn exec:java -pl benchmark +``` + +## Sample results + +``` +Benchmark Mode Cnt Score Error Units +CedarBenchmark.jniCachedLarge avgt 3 1077.137 ± 2500.105 us/op +CedarBenchmark.jniCachedMedium avgt 3 68.613 ± 44.063 us/op +CedarBenchmark.jniCachedSmall avgt 3 29.142 ± 7.306 us/op +CedarBenchmark.jniCachedXLarge avgt 3 476.329 ± 1036.358 us/op +CedarBenchmark.jniUncachedLarge avgt 3 2209.292 ± 2208.573 us/op +CedarBenchmark.jniUncachedMedium avgt 3 335.507 ± 269.147 us/op +CedarBenchmark.jniUncachedSmall avgt 3 64.400 ± 20.911 us/op +CedarBenchmark.jniUncachedXLarge avgt 3 9672.329 ± 1197.832 us/op +CedarBenchmark.wasmCachedLarge avgt 3 2080.047 ± 1085.834 us/op +CedarBenchmark.wasmCachedMedium avgt 3 218.603 ± 112.547 us/op +CedarBenchmark.wasmCachedSmall avgt 3 131.787 ± 148.610 us/op +CedarBenchmark.wasmCachedXLarge avgt 3 906.421 ± 664.702 us/op +CedarBenchmark.wasmUncachedLarge avgt 3 2788.757 ± 364.868 us/op +CedarBenchmark.wasmUncachedMedium avgt 3 840.366 ± 441.384 us/op +CedarBenchmark.wasmUncachedSmall avgt 3 233.398 ± 41.424 us/op +CedarBenchmark.wasmUncachedXLarge avgt 3 21890.498 ± 12325.095 us/op +``` diff --git a/CedarWasm/benchmark/pom.xml b/CedarWasm/benchmark/pom.xml new file mode 100644 index 00000000..2890c16f --- /dev/null +++ b/CedarWasm/benchmark/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + com.cedarpolicy + cedar-wasm-parent + 4.0.0-SNAPSHOT + + cedar-benchmark + Cedar JNI vs Wasm Benchmark + + + + + com.cedarpolicy + cedar-wasm + ${project.version} + + + + com.cedarpolicy + cedar-java + 3.1.2 + system + ${project.basedir}/../../CedarJava/build/libs/CedarJava-uber.jar + + + + com.fasterxml.jackson.core + jackson-databind + 2.20.0 + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + 2.20.0 + + + com.fizzed + jne + 4.5.3 + + + com.google.guava + guava + 33.5.0-jre + + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + test + org.openjdk.jmh.Main + + -f + 0 + -i + 3 + -wi + 3 + -r + 1 + -w + 1 + + + + + + diff --git a/CedarWasm/benchmark/src/test/java/com/cedarpolicy/benchmark/CedarBenchmark.java b/CedarWasm/benchmark/src/test/java/com/cedarpolicy/benchmark/CedarBenchmark.java new file mode 100644 index 00000000..dba554a0 --- /dev/null +++ b/CedarWasm/benchmark/src/test/java/com/cedarpolicy/benchmark/CedarBenchmark.java @@ -0,0 +1,357 @@ +package com.cedarpolicy.benchmark; + +import com.cedarpolicy.BasicAuthorizationEngine; +import com.cedarpolicy.model.AuthorizationRequest; +import com.cedarpolicy.model.AuthorizationResponse; +import com.cedarpolicy.model.entity.Entity; +import com.cedarpolicy.model.entity.Entities; +import com.cedarpolicy.model.exception.AuthException; +import com.cedarpolicy.model.policy.Policy; +import com.cedarpolicy.model.policy.PolicySet; +import com.cedarpolicy.value.EntityTypeName; +import com.cedarpolicy.wasm.CedarEngine; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(1) +@State(Scope.Benchmark) +public class CedarBenchmark { + + // --- JNI --- + private BasicAuthorizationEngine jniEngine; + private AuthorizationRequest jniSmallRequest; + private PolicySet jniSmallPolicySet; + private Set jniSmallEntities; + + private AuthorizationRequest jniMediumRequest; + private PolicySet jniMediumPolicySet; + private Set jniMediumEntities; + + private AuthorizationRequest jniLargeRequest; + private PolicySet jniLargePolicySet; + private Set jniLargeEntities; + + private PolicySet jniXLargePolicySet; + + // --- JNI Cached --- + private PolicySet jniSmallPolicySetCached; + private PolicySet jniMediumPolicySetCached; + private PolicySet jniLargePolicySetCached; + private PolicySet jniXLargePolicySetCached; + + // --- Wasm --- + private CedarEngine wasmEngine; + private String wasmSmallRequest; + private String wasmMediumRequest; + private String wasmLargeRequest; + private String wasmXLargeRequest; + + // --- Wasm Cached --- + private String wasmCachedSmallRequest; + private String wasmCachedMediumRequest; + private String wasmCachedLargeRequest; + private String wasmCachedXLargeRequest; + + @Setup(Level.Trial) + public void setUp() throws Exception { + setUpJni(); + setUpWasm(); + } + + // ── JNI setup ────────────────────────────────────────────────────── + + private void setUpJni() throws Exception { + jniEngine = new BasicAuthorizationEngine(); + + EntityTypeName userType = EntityTypeName.parse("User").get(); + EntityTypeName actionType = EntityTypeName.parse("Action").get(); + EntityTypeName resourceType = EntityTypeName.parse("Resource").get(); + EntityTypeName photoType = EntityTypeName.parse("Photo").get(); + EntityTypeName docType = EntityTypeName.parse("Document").get(); + + jniSmallRequest = new AuthorizationRequest( + userType.of("alice"), actionType.of("view"), resourceType.of("doc1"), new HashMap<>()); + jniSmallPolicySet = PolicySet.parsePolicies(readResource("/small_policies.cedar")); + jniSmallEntities = Entities.parse(readResource("/small_entities.json")).getEntities(); + + jniMediumRequest = new AuthorizationRequest( + userType.of("alice"), actionType.of("View_Photo"), photoType.of("pic01"), new HashMap<>()); + jniMediumPolicySet = PolicySet.parsePolicies(readResource("/medium_policies.cedar")); + jniMediumEntities = Entities.parse(readResource("/medium_entities.json")).getEntities(); + + HashMap context = new HashMap<>(); + context.put("sourceIp", new com.cedarpolicy.value.IpAddress("10.1.2.3")); + context.put("requestId", new com.cedarpolicy.value.PrimString("req-bench-001")); + jniLargeRequest = new AuthorizationRequest( + userType.of("alice"), actionType.of("ViewDocument"), docType.of("doc1"), context); + jniLargePolicySet = PolicySet.parsePolicies(readResource("/large_policies.cedar")); + jniLargeEntities = Entities.parse(readResource("/large_entities.json")).getEntities(); + + jniXLargePolicySet = buildJniXLargePolicies(); + + jniSmallPolicySetCached = PolicySet.parsePolicies(readResource("/small_policies.cedar")); + jniSmallPolicySetCached.cache(); + jniMediumPolicySetCached = PolicySet.parsePolicies(readResource("/medium_policies.cedar")); + jniMediumPolicySetCached.cache(); + jniLargePolicySetCached = PolicySet.parsePolicies(readResource("/large_policies.cedar")); + jniLargePolicySetCached.cache(); + jniXLargePolicySetCached = buildJniXLargePolicies(); + jniXLargePolicySetCached.cache(); + } + + private static PolicySet buildJniXLargePolicies() throws Exception { + String[] roles = {"admin", "editor", "viewer", "auditor", "deployer", + "security", "billing", "support", "developer", "manager"}; + String[] resources = {"Document", "Folder", "Project", "Comment", "Webhook", + "ApiKey", "AuditLog", "Setting", "Integration", "Pipeline", + "Environment", "Secret", "Deployment", "Report", "Dashboard", + "Alert", "Ticket", "Release", "Config", "Template"}; + StringBuilder policies = new StringBuilder(); + for (String role : roles) { + for (String resource : resources) { + policies.append(String.format( + "permit(principal in UserGroup::\"%s-group\", action, resource is %s);%n", + role, resource)); + } + } + for (String resource : resources) { + policies.append(String.format( + "forbid(principal, action, resource is %s) when { resource.archived };%n", + resource)); + } + return PolicySet.parsePolicies(policies.toString()); + } + + // ── Wasm setup ───────────────────────────────────────────────────── + + private void setUpWasm() throws Exception { + wasmEngine = CedarEngine.create(); + + String smallPolicies = readResource("/small_policies.cedar"); + String smallEntities = readResource("/small_entities.json"); + String mediumPolicies = readResource("/medium_policies.cedar"); + String mediumEntities = readResource("/medium_entities.json"); + String largePolicies = readResource("/large_policies.cedar"); + String largeEntities = readResource("/large_entities.json"); + String xlargePolicies = buildXLargePoliciesCedar(); + + String largeContext = "{\"sourceIp\": {\"__extn\": {\"fn\": \"ip\", \"arg\": \"10.1.2.3\"}}, \"requestId\": \"req-bench-001\"}"; + + wasmSmallRequest = wasmAuthRequest( + entity("User", "alice"), entity("Action", "view"), entity("Resource", "doc1"), + "{}", smallPolicies, smallEntities); + wasmMediumRequest = wasmAuthRequest( + entity("User", "alice"), entity("Action", "View_Photo"), entity("Photo", "pic01"), + "{}", mediumPolicies, mediumEntities); + wasmLargeRequest = wasmAuthRequest( + entity("User", "alice"), entity("Action", "ViewDocument"), entity("Document", "doc1"), + largeContext, largePolicies, largeEntities); + wasmXLargeRequest = wasmAuthRequest( + entity("User", "alice"), entity("Action", "view"), entity("Resource", "doc1"), + "{}", xlargePolicies, smallEntities); + + // Pre-parse policy sets for cached benchmarks + wasmEngine.preParsePolicySet("small", policySetFromCedar(smallPolicies)); + wasmEngine.preParsePolicySet("medium", policySetFromCedar(mediumPolicies)); + wasmEngine.preParsePolicySet("large", policySetFromCedar(largePolicies)); + wasmEngine.preParsePolicySet("xlarge", policySetFromCedar(xlargePolicies)); + + wasmCachedSmallRequest = statefulRequest( + entity("User", "alice"), entity("Action", "view"), entity("Resource", "doc1"), + "{}", "small", smallEntities); + wasmCachedMediumRequest = statefulRequest( + entity("User", "alice"), entity("Action", "View_Photo"), entity("Photo", "pic01"), + "{}", "medium", mediumEntities); + wasmCachedLargeRequest = statefulRequest( + entity("User", "alice"), entity("Action", "ViewDocument"), entity("Document", "doc1"), + largeContext, "large", largeEntities); + wasmCachedXLargeRequest = statefulRequest( + entity("User", "alice"), entity("Action", "view"), entity("Resource", "doc1"), + "{}", "xlarge", smallEntities); + } + + private static String buildXLargePoliciesCedar() { + String[] roles = {"admin", "editor", "viewer", "auditor", "deployer", + "security", "billing", "support", "developer", "manager"}; + String[] resources = {"Document", "Folder", "Project", "Comment", "Webhook", + "ApiKey", "AuditLog", "Setting", "Integration", "Pipeline", + "Environment", "Secret", "Deployment", "Report", "Dashboard", + "Alert", "Ticket", "Release", "Config", "Template"}; + var sb = new StringBuilder(); + for (String role : roles) { + for (String resource : resources) { + sb.append(String.format( + "permit(principal in UserGroup::\"%s-group\", action, resource is %s);%n", + role, resource)); + } + } + for (String resource : resources) { + sb.append(String.format( + "forbid(principal, action, resource is %s) when { resource.archived };%n", + resource)); + } + return sb.toString(); + } + + // ── Wasm helpers ─────────────────────────────────────────────────── + + private static String entity(String type, String id) { + return "{\"type\": \"" + type + "\", \"id\": \"" + id + "\"}"; + } + + private static String policySetFromCedar(String cedarText) { + String[] policyTexts = cedarText.split("(?<=;)\\s*\n"); + var sb = new StringBuilder("{\"staticPolicies\": {"); + int idx = 0; + for (String p : policyTexts) { + String trimmed = p.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("//")) continue; + if (idx > 0) sb.append(", "); + sb.append("\"p").append(idx).append("\": "); + sb.append("\"").append(trimmed.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")).append("\""); + idx++; + } + sb.append("}, \"templates\": {}, \"templateLinks\": []}"); + return sb.toString(); + } + + private static String wasmAuthRequest(String principal, String action, String resource, + String context, String policiesCedar, String entitiesJson) { + String policySet = policySetFromCedar(policiesCedar); + return "{\"principal\": " + principal + ", \"action\": " + action + ", \"resource\": " + resource + + ", \"context\": " + context + ", \"policies\": " + policySet + ", \"entities\": " + entitiesJson + "}"; + } + + private static String statefulRequest(String principal, String action, String resource, + String context, String policySetId, String entitiesJson) { + return "{\"principal\": " + principal + ", \"action\": " + action + ", \"resource\": " + resource + + ", \"context\": " + context + ", \"preparsedPolicySetId\": \"" + policySetId + + "\", \"validateRequest\": false, \"entities\": " + entitiesJson + "}"; + } + + // ── Utilities ────────────────────────────────────────────────────── + + private static String readResource(String path) throws Exception { + return new String( + Files.readAllBytes(Paths.get(CedarBenchmark.class.getResource(path).toURI())), + StandardCharsets.UTF_8); + } + + // ═══════════════════════════════════════════════════════════════════ + // JNI Benchmarks + // ═══════════════════════════════════════════════════════════════════ + + @Benchmark + public AuthorizationResponse jniUncachedSmall() throws AuthException { + return jniEngine.isAuthorized(jniSmallRequest, jniSmallPolicySet, jniSmallEntities); + } + + @Benchmark + public AuthorizationResponse jniUncachedMedium() throws AuthException { + return jniEngine.isAuthorized(jniMediumRequest, jniMediumPolicySet, jniMediumEntities); + } + + @Benchmark + public AuthorizationResponse jniUncachedLarge() throws AuthException { + return jniEngine.isAuthorized(jniLargeRequest, jniLargePolicySet, jniLargeEntities); + } + + @Benchmark + public AuthorizationResponse jniUncachedXLarge() throws AuthException { + return jniEngine.isAuthorized(jniSmallRequest, jniXLargePolicySet, jniSmallEntities); + } + + // ═══════════════════════════════════════════════════════════════════ + // JNI Cached Benchmarks + // ═══════════════════════════════════════════════════════════════════ + + @Benchmark + public AuthorizationResponse jniCachedSmall() throws AuthException { + return jniEngine.isAuthorized(jniSmallRequest, jniSmallPolicySetCached, jniSmallEntities); + } + + @Benchmark + public AuthorizationResponse jniCachedMedium() throws AuthException { + return jniEngine.isAuthorized(jniMediumRequest, jniMediumPolicySetCached, jniMediumEntities); + } + + @Benchmark + public AuthorizationResponse jniCachedLarge() throws AuthException { + return jniEngine.isAuthorized(jniLargeRequest, jniLargePolicySetCached, jniLargeEntities); + } + + @Benchmark + public AuthorizationResponse jniCachedXLarge() throws AuthException { + return jniEngine.isAuthorized(jniSmallRequest, jniXLargePolicySetCached, jniSmallEntities); + } + + // ═══════════════════════════════════════════════════════════════════ + // Wasm Benchmarks + // ═══════════════════════════════════════════════════════════════════ + + @Benchmark + public String wasmUncachedSmall() { + return wasmEngine.authorize( wasmSmallRequest); + } + + @Benchmark + public String wasmUncachedMedium() { + return wasmEngine.authorize( wasmMediumRequest); + } + + @Benchmark + public String wasmUncachedLarge() { + return wasmEngine.authorize( wasmLargeRequest); + } + + @Benchmark + public String wasmUncachedXLarge() { + return wasmEngine.authorize( wasmXLargeRequest); + } + + // ═══════════════════════════════════════════════════════════════════ + // Wasm Cached Benchmarks + // ═══════════════════════════════════════════════════════════════════ + + @Benchmark + public String wasmCachedSmall() { + return wasmEngine.statefulAuthorize(wasmCachedSmallRequest); + } + + @Benchmark + public String wasmCachedMedium() { + return wasmEngine.statefulAuthorize(wasmCachedMediumRequest); + } + + @Benchmark + public String wasmCachedLarge() { + return wasmEngine.statefulAuthorize(wasmCachedLargeRequest); + } + + @Benchmark + public String wasmCachedXLarge() { + return wasmEngine.statefulAuthorize(wasmCachedXLargeRequest); + } +} diff --git a/CedarWasm/benchmark/src/test/resources/large_entities.json b/CedarWasm/benchmark/src/test/resources/large_entities.json new file mode 100644 index 00000000..a340bfe1 --- /dev/null +++ b/CedarWasm/benchmark/src/test/resources/large_entities.json @@ -0,0 +1,54 @@ +[ + {"uid": {"type": "Organization", "id": "acme"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Department", "id": "engineering"}, "attrs": {}, "parents": [{"type": "Organization", "id": "acme"}]}, + {"uid": {"type": "Department", "id": "product"}, "attrs": {}, "parents": [{"type": "Organization", "id": "acme"}]}, + {"uid": {"type": "Team", "id": "engineering"}, "attrs": {}, "parents": [{"type": "Department", "id": "engineering"}]}, + {"uid": {"type": "Team", "id": "platform"}, "attrs": {}, "parents": [{"type": "Department", "id": "engineering"}]}, + {"uid": {"type": "Team", "id": "design"}, "attrs": {}, "parents": [{"type": "Department", "id": "product"}]}, + {"uid": {"type": "UserGroup", "id": "org-admins"}, "attrs": {}, "parents": []}, + {"uid": {"type": "UserGroup", "id": "project-admins"}, "attrs": {}, "parents": []}, + {"uid": {"type": "UserGroup", "id": "developers"}, "attrs": {}, "parents": []}, + {"uid": {"type": "UserGroup", "id": "viewers"}, "attrs": {}, "parents": []}, + {"uid": {"type": "User", "id": "alice"}, "attrs": {"email": "alice@acme.com", "role": "admin", "mfaEnabled": true, "loginCount": 542, "lastLoginIp": {"__extn": {"fn": "ip", "arg": "10.1.2.3"}}}, "parents": [{"type": "Team", "id": "engineering"}, {"type": "UserGroup", "id": "org-admins"}, {"type": "UserGroup", "id": "project-admins"}]}, + {"uid": {"type": "User", "id": "bob"}, "attrs": {"email": "bob@acme.com", "role": "developer", "mfaEnabled": true, "loginCount": 201, "lastLoginIp": {"__extn": {"fn": "ip", "arg": "10.1.2.4"}}}, "parents": [{"type": "Team", "id": "engineering"}, {"type": "UserGroup", "id": "developers"}]}, + {"uid": {"type": "User", "id": "carol"}, "attrs": {"email": "carol@acme.com", "role": "developer", "mfaEnabled": false, "loginCount": 89, "lastLoginIp": {"__extn": {"fn": "ip", "arg": "10.1.2.5"}}}, "parents": [{"type": "Team", "id": "platform"}, {"type": "UserGroup", "id": "developers"}]}, + {"uid": {"type": "User", "id": "dave"}, "attrs": {"email": "dave@acme.com", "role": "designer", "mfaEnabled": true, "loginCount": 45, "lastLoginIp": {"__extn": {"fn": "ip", "arg": "192.168.1.10"}}}, "parents": [{"type": "Team", "id": "design"}, {"type": "UserGroup", "id": "viewers"}]}, + {"uid": {"type": "User", "id": "eve"}, "attrs": {"email": "eve@acme.com", "role": "manager", "mfaEnabled": true, "loginCount": 312, "lastLoginIp": {"__extn": {"fn": "ip", "arg": "10.1.3.1"}}}, "parents": [{"type": "Department", "id": "engineering"}, {"type": "UserGroup", "id": "project-admins"}]}, + {"uid": {"type": "ServiceAccount", "id": "ci-bot"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "alice"}}, "scopes": ["read", "deploy"]}, "parents": [{"type": "ServiceRole", "id": "deployer"}]}, + {"uid": {"type": "ServiceAccount", "id": "monitoring"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "bob"}}, "scopes": ["read"]}, "parents": [{"type": "ServiceRole", "id": "reader"}]}, + {"uid": {"type": "ServiceRole", "id": "deployer"}, "attrs": {}, "parents": []}, + {"uid": {"type": "ServiceRole", "id": "reader"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Project", "id": "proj1"}, "attrs": {"visibility": "internal", "archived": false}, "parents": [{"type": "Organization", "id": "acme"}]}, + {"uid": {"type": "Project", "id": "proj2"}, "attrs": {"visibility": "public", "archived": false}, "parents": [{"type": "Organization", "id": "acme"}]}, + {"uid": {"type": "Folder", "id": "root"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "alice"}}}, "parents": [{"type": "Project", "id": "proj1"}]}, + {"uid": {"type": "Folder", "id": "designs"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "dave"}}}, "parents": [{"type": "Folder", "id": "root"}]}, + {"uid": {"type": "Folder", "id": "src"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "bob"}}}, "parents": [{"type": "Folder", "id": "root"}]}, + {"uid": {"type": "Document", "id": "doc1"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "alice"}}, "classification": "internal", "createdAt": 1700000000, "tags": ["architecture", "design"]}, "parents": [{"type": "Folder", "id": "root"}]}, + {"uid": {"type": "Document", "id": "doc2"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "bob"}}, "classification": "restricted", "createdAt": 1700100000, "tags": ["security", "credentials"]}, "parents": [{"type": "Folder", "id": "src"}]}, + {"uid": {"type": "Document", "id": "doc3"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "carol"}}, "classification": "public", "createdAt": 1700200000, "tags": ["readme"]}, "parents": [{"type": "Folder", "id": "src"}]}, + {"uid": {"type": "Document", "id": "doc4"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "dave"}}, "classification": "internal", "createdAt": 1700300000, "tags": ["mockup"]}, "parents": [{"type": "Folder", "id": "designs"}]}, + {"uid": {"type": "Comment", "id": "c1"}, "attrs": {"author": {"__entity": {"type": "User", "id": "bob"}}}, "parents": [{"type": "Document", "id": "doc1"}]}, + {"uid": {"type": "Comment", "id": "c2"}, "attrs": {"author": {"__entity": {"type": "User", "id": "carol"}}}, "parents": [{"type": "Document", "id": "doc1"}]}, + {"uid": {"type": "Webhook", "id": "wh1"}, "attrs": {"url": "https://hooks.example.com/deploy", "active": true}, "parents": [{"type": "Project", "id": "proj1"}]}, + {"uid": {"type": "ApiKey", "id": "key1"}, "attrs": {"expiresAt": 1800000000}, "parents": [{"type": "ServiceAccount", "id": "ci-bot"}]}, + {"uid": {"type": "AuditLog", "id": "audit-acme"}, "attrs": {}, "parents": [{"type": "Organization", "id": "acme"}]}, + {"uid": {"type": "Setting", "id": "proj1-settings"}, "attrs": {}, "parents": [{"type": "Project", "id": "proj1"}]}, + {"uid": {"type": "Integration", "id": "github"}, "attrs": {"provider": "github", "enabled": true}, "parents": [{"type": "Project", "id": "proj1"}]}, + {"uid": {"type": "Pipeline", "id": "pipe1"}, "attrs": {"status": "idle"}, "parents": [{"type": "Project", "id": "proj1"}]}, + {"uid": {"type": "Environment", "id": "staging"}, "attrs": {"tier": "staging", "locked": false}, "parents": [{"type": "Project", "id": "proj1"}]}, + {"uid": {"type": "Environment", "id": "production"}, "attrs": {"tier": "production", "locked": true}, "parents": [{"type": "Project", "id": "proj1"}]}, + {"uid": {"type": "Secret", "id": "db-password"}, "attrs": {"rotatedAt": 1699000000}, "parents": [{"type": "Environment", "id": "production"}]}, + {"uid": {"type": "Deployment", "id": "deploy-v1"}, "attrs": {"version": "1.2.3", "deployer": {"__entity": {"type": "User", "id": "alice"}}}, "parents": [{"type": "Environment", "id": "staging"}]}, + {"uid": {"type": "Action", "id": "ViewDocument"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "EditDocument"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "DeleteDocument"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "ShareDocument"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "CreateDocument"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "CreateFolder"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "ViewProject"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "EditProject"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "Deploy"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "ViewSecret"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "TriggerPipeline"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "ViewPipeline"}, "attrs": {}, "parents": []} +] diff --git a/CedarWasm/benchmark/src/test/resources/large_policies.cedar b/CedarWasm/benchmark/src/test/resources/large_policies.cedar new file mode 100644 index 00000000..f9164d43 --- /dev/null +++ b/CedarWasm/benchmark/src/test/resources/large_policies.cedar @@ -0,0 +1,63 @@ +// Organization-wide read access for all members +permit(principal, action == Action::"ViewDocument", resource) +when { resource in principal }; + +// Project owners get full access +permit(principal, action, resource) +when { resource in Project::"proj1" && principal == Project::"proj1".owner }; + +// Team leads can manage their team's projects +permit(principal in Team::"engineering", action == Action::"EditProject", resource in Project::"proj1"); +permit(principal in Team::"engineering", action == Action::"DeleteProject", resource in Project::"proj1"); + +// Document owners can edit and delete their own documents +permit(principal, action == Action::"EditDocument", resource) +when { principal == resource.owner }; +permit(principal, action == Action::"DeleteDocument", resource) +when { principal == resource.owner }; +permit(principal, action == Action::"ShareDocument", resource) +when { principal == resource.owner }; + +// Comment authors can delete their own comments +permit(principal, action == Action::"DeleteComment", resource) +when { principal == resource.author }; + +// Anyone in a project can create documents and folders +permit(principal, action == Action::"CreateDocument", resource in Project::"proj1") +when { principal in Department::"engineering" }; +permit(principal, action == Action::"CreateFolder", resource in Project::"proj1") +when { principal in Department::"engineering" }; + +// Pipeline access for CI service accounts +permit(principal == ServiceAccount::"ci-bot", action == Action::"TriggerPipeline", resource in Project::"proj1"); +permit(principal == ServiceAccount::"ci-bot", action == Action::"ViewPipeline", resource in Project::"proj1"); + +// Deployment requires MFA +permit(principal, action == Action::"Deploy", resource) +when { principal.mfaEnabled }; + +// Secrets require MFA and internal IP +permit(principal, action == Action::"ViewSecret", resource) +when { principal.mfaEnabled && context.sourceIp.isInRange(ip("10.0.0.0/8")) }; +permit(principal, action == Action::"EditSecret", resource) +when { principal.mfaEnabled && context.sourceIp.isInRange(ip("10.0.0.0/8")) }; + +// Audit logs only for org admins +permit(principal in UserGroup::"org-admins", action == Action::"ViewAuditLog", resource); + +// Settings management for project admins +permit(principal in UserGroup::"project-admins", action == Action::"EditSetting", resource in Project::"proj1"); +permit(principal, action == Action::"ViewSetting", resource in Project::"proj1") +when { principal in Department::"engineering" }; + +// Forbid access to archived projects +forbid(principal, action, resource in Project::"proj1") +when { Project::"proj1".archived }; + +// Forbid deployments to locked environments +forbid(principal, action == Action::"Deploy", resource) +when { resource.locked }; + +// Forbid access from external IPs to classified documents +forbid(principal, action == Action::"ViewDocument", resource) +when { resource.classification == "restricted" && !context.sourceIp.isInRange(ip("10.0.0.0/8")) }; diff --git a/CedarWasm/benchmark/src/test/resources/large_schema.json b/CedarWasm/benchmark/src/test/resources/large_schema.json new file mode 100644 index 00000000..f69aeaad --- /dev/null +++ b/CedarWasm/benchmark/src/test/resources/large_schema.json @@ -0,0 +1,372 @@ +{ + "": { + "entityTypes": { + "User": { + "memberOfTypes": ["UserGroup", "Team", "Department"], + "shape": { + "type": "Record", + "attributes": { + "email": { "type": "String" }, + "role": { "type": "String" }, + "mfaEnabled": { "type": "Boolean" }, + "loginCount": { "type": "Long" }, + "lastLoginIp": { "type": "Extension", "name": "ipaddr" } + } + } + }, + "UserGroup": { + "memberOfTypes": ["UserGroup"] + }, + "Team": { + "memberOfTypes": ["Department"] + }, + "Department": { + "memberOfTypes": ["Organization"] + }, + "Organization": {}, + "ServiceAccount": { + "memberOfTypes": ["ServiceRole"], + "shape": { + "type": "Record", + "attributes": { + "owner": { "type": "Entity", "name": "User" }, + "scopes": { "type": "Set", "element": { "type": "String" } } + } + } + }, + "ServiceRole": {}, + "Document": { + "memberOfTypes": ["Folder", "Project"], + "shape": { + "type": "Record", + "attributes": { + "owner": { "type": "Entity", "name": "User" }, + "classification": { "type": "String" }, + "createdAt": { "type": "Long" }, + "tags": { "type": "Set", "element": { "type": "String" } } + } + } + }, + "Folder": { + "memberOfTypes": ["Folder", "Project"], + "shape": { + "type": "Record", + "attributes": { + "owner": { "type": "Entity", "name": "User" } + } + } + }, + "Project": { + "memberOfTypes": ["Organization"], + "shape": { + "type": "Record", + "attributes": { + "visibility": { "type": "String" }, + "archived": { "type": "Boolean" } + } + } + }, + "Comment": { + "memberOfTypes": ["Document"], + "shape": { + "type": "Record", + "attributes": { + "author": { "type": "Entity", "name": "User" } + } + } + }, + "Webhook": { + "memberOfTypes": ["Project"], + "shape": { + "type": "Record", + "attributes": { + "url": { "type": "String" }, + "active": { "type": "Boolean" } + } + } + }, + "ApiKey": { + "memberOfTypes": ["ServiceAccount"], + "shape": { + "type": "Record", + "attributes": { + "expiresAt": { "type": "Long" } + } + } + }, + "AuditLog": { + "memberOfTypes": ["Organization"] + }, + "Setting": { + "memberOfTypes": ["Project", "Organization"] + }, + "Integration": { + "memberOfTypes": ["Project"], + "shape": { + "type": "Record", + "attributes": { + "provider": { "type": "String" }, + "enabled": { "type": "Boolean" } + } + } + }, + "Pipeline": { + "memberOfTypes": ["Project"], + "shape": { + "type": "Record", + "attributes": { + "status": { "type": "String" } + } + } + }, + "Environment": { + "memberOfTypes": ["Project"], + "shape": { + "type": "Record", + "attributes": { + "tier": { "type": "String" }, + "locked": { "type": "Boolean" } + } + } + }, + "Secret": { + "memberOfTypes": ["Environment", "Project"], + "shape": { + "type": "Record", + "attributes": { + "rotatedAt": { "type": "Long" } + } + } + }, + "Deployment": { + "memberOfTypes": ["Environment"], + "shape": { + "type": "Record", + "attributes": { + "version": { "type": "String" }, + "deployer": { "type": "Entity", "name": "User" } + } + } + } + }, + "actions": { + "ViewDocument": { + "appliesTo": { + "principalTypes": ["User", "ServiceAccount"], + "resourceTypes": ["Document"], + "context": { + "type": "Record", + "attributes": { + "sourceIp": { "type": "Extension", "name": "ipaddr" }, + "requestId": { "type": "String" } + } + } + } + }, + "EditDocument": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Document"], + "context": { + "type": "Record", + "attributes": { + "sourceIp": { "type": "Extension", "name": "ipaddr" }, + "requestId": { "type": "String" } + } + } + } + }, + "DeleteDocument": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Document"], + "context": { + "type": "Record", + "attributes": { + "sourceIp": { "type": "Extension", "name": "ipaddr" }, + "requestId": { "type": "String" } + } + } + } + }, + "ShareDocument": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Document"], + "context": { + "type": "Record", + "attributes": { + "sourceIp": { "type": "Extension", "name": "ipaddr" }, + "shareWith": { "type": "Entity", "name": "User" } + } + } + } + }, + "CreateDocument": { + "appliesTo": { + "principalTypes": ["User", "ServiceAccount"], + "resourceTypes": ["Folder", "Project"] + } + }, + "CreateFolder": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Folder", "Project"] + } + }, + "DeleteFolder": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Folder"] + } + }, + "ViewProject": { + "appliesTo": { + "principalTypes": ["User", "ServiceAccount"], + "resourceTypes": ["Project"] + } + }, + "EditProject": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Project"] + } + }, + "DeleteProject": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Project"] + } + }, + "CreateComment": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Document"] + } + }, + "DeleteComment": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Comment"] + } + }, + "ManageWebhook": { + "appliesTo": { + "principalTypes": ["User", "ServiceAccount"], + "resourceTypes": ["Webhook"] + } + }, + "CreateApiKey": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["ServiceAccount"] + } + }, + "RevokeApiKey": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["ApiKey"] + } + }, + "ViewAuditLog": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["AuditLog"] + } + }, + "EditSetting": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Setting"] + } + }, + "ViewSetting": { + "appliesTo": { + "principalTypes": ["User", "ServiceAccount"], + "resourceTypes": ["Setting"] + } + }, + "ManageIntegration": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Integration"] + } + }, + "TriggerPipeline": { + "appliesTo": { + "principalTypes": ["User", "ServiceAccount"], + "resourceTypes": ["Pipeline"] + } + }, + "CancelPipeline": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Pipeline"] + } + }, + "ViewPipeline": { + "appliesTo": { + "principalTypes": ["User", "ServiceAccount"], + "resourceTypes": ["Pipeline"] + } + }, + "Deploy": { + "appliesTo": { + "principalTypes": ["User", "ServiceAccount"], + "resourceTypes": ["Environment"], + "context": { + "type": "Record", + "attributes": { + "version": { "type": "String" }, + "sourceIp": { "type": "Extension", "name": "ipaddr" } + } + } + } + }, + "Rollback": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Deployment"] + } + }, + "ViewSecret": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Secret"], + "context": { + "type": "Record", + "attributes": { + "sourceIp": { "type": "Extension", "name": "ipaddr" }, + "reason": { "type": "String" } + } + } + } + }, + "EditSecret": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Secret"], + "context": { + "type": "Record", + "attributes": { + "sourceIp": { "type": "Extension", "name": "ipaddr" }, + "reason": { "type": "String" } + } + } + } + }, + "ManageMembers": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Team", "Department", "Organization"] + } + }, + "TransferOwnership": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Project", "Document"] + } + } + } + } +} diff --git a/CedarWasm/benchmark/src/test/resources/medium_entities.json b/CedarWasm/benchmark/src/test/resources/medium_entities.json new file mode 100644 index 00000000..99c3194b --- /dev/null +++ b/CedarWasm/benchmark/src/test/resources/medium_entities.json @@ -0,0 +1,7 @@ +[ + {"uid": {"type": "User", "id": "alice"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "View_Photo"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Photo", "id": "pic01"}, "attrs": {}, "parents": [{"type": "Album", "id": "vacation"}]}, + {"uid": {"type": "Album", "id": "vacation"}, "attrs": {}, "parents": [{"type": "Account", "id": "account1"}]}, + {"uid": {"type": "Account", "id": "account1"}, "attrs": {}, "parents": []} +] diff --git a/CedarWasm/benchmark/src/test/resources/medium_policies.cedar b/CedarWasm/benchmark/src/test/resources/medium_policies.cedar new file mode 100644 index 00000000..1b00713d --- /dev/null +++ b/CedarWasm/benchmark/src/test/resources/medium_policies.cedar @@ -0,0 +1,9 @@ +permit(principal == User::"alice", action == Action::"View_Photo", resource); + +permit(principal, action == Action::"View_Photo", resource in Album::"vacation"); + +forbid(principal, action, resource) when { resource.private }; + +permit(principal, action == Action::"Edit_Photo", resource) when { principal == resource.owner }; + +permit(principal, action == Action::"Delete_Photo", resource) when { principal == resource.owner }; diff --git a/CedarWasm/benchmark/src/test/resources/photoflash_schema.json b/CedarWasm/benchmark/src/test/resources/photoflash_schema.json new file mode 100644 index 00000000..2875b8f0 --- /dev/null +++ b/CedarWasm/benchmark/src/test/resources/photoflash_schema.json @@ -0,0 +1,65 @@ +{ + "": { + "entityTypes": { + "User": { + "memberOfTypes": [ + "UserGroup" + ] + }, + "Photo": { + "memberOfTypes": [ + "Album", + "Account" + ] + }, + "Album": {}, + "UserGroup": {}, + "Account": {} + }, + "actions": { + "readOnly": {}, + "readWrite": {}, + "createAlbum": { + "appliesTo": { + "resourceTypes": [ + "Account", + "Album" + ], + "principalTypes": [ + "User" + ] + } + }, + "addPhotoToAlbum": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "Album" + ] + } + }, + "viewPhoto": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "Photo" + ] + } + }, + "viewComments": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "Photo" + ] + } + } + } + } +} diff --git a/CedarWasm/benchmark/src/test/resources/small_entities.json b/CedarWasm/benchmark/src/test/resources/small_entities.json new file mode 100644 index 00000000..49905922 --- /dev/null +++ b/CedarWasm/benchmark/src/test/resources/small_entities.json @@ -0,0 +1,5 @@ +[ + {"uid": {"type": "User", "id": "alice"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "view"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Resource", "id": "doc1"}, "attrs": {}, "parents": []} +] diff --git a/CedarWasm/benchmark/src/test/resources/small_policies.cedar b/CedarWasm/benchmark/src/test/resources/small_policies.cedar new file mode 100644 index 00000000..41c318cd --- /dev/null +++ b/CedarWasm/benchmark/src/test/resources/small_policies.cedar @@ -0,0 +1 @@ +permit(principal, action, resource); diff --git a/CedarWasm/core/pom.xml b/CedarWasm/core/pom.xml new file mode 100644 index 00000000..2008ea25 --- /dev/null +++ b/CedarWasm/core/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.cedarpolicy + cedar-wasm-parent + 4.0.0-SNAPSHOT + + cedar-wasm + Cedar Wasm + + + + io.roastedroot + redline + ${redline.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + + + + + io.roastedroot + redline-compiler-maven-plugin + ${redline.version} + + + compile-cedar-wasm + + compile + + + com.cedarpolicy.wasm.CedarWasmModule + ${project.basedir}/wasm/cedar_wasm.wasm + com.cedarpolicy.wasm.CedarWasmExports + true + + x86_64-unknown-linux-gnu + + + + + + + + diff --git a/CedarWasm/core/src/main/java/com/cedarpolicy/wasm/CedarEngine.java b/CedarWasm/core/src/main/java/com/cedarpolicy/wasm/CedarEngine.java new file mode 100644 index 00000000..a714e738 --- /dev/null +++ b/CedarWasm/core/src/main/java/com/cedarpolicy/wasm/CedarEngine.java @@ -0,0 +1,109 @@ +package com.cedarpolicy.wasm; + +public class CedarEngine implements AutoCloseable { + private final CedarWasm wasm; + + private CedarEngine(CedarWasm wasm) { + this.wasm = wasm; + } + + public static CedarEngine create() { + return new CedarEngine(CedarWasm.create()); + } + + public String authorize(String requestJson) { + return wasm.authorize(requestJson); + } + + public String statefulAuthorize(String requestJson) { + return wasm.statefulAuthorize(requestJson); + } + + public String validate(String requestJson) { + return wasm.call("ValidateOperation", requestJson); + } + + public String validateEntities(String requestJson) { + return wasm.call("ValidateEntities", requestJson); + } + + public String validateWithLevel(String requestJson) { + return wasm.call("ValidateWithLevelOperation", requestJson); + } + + public String parsePolicy(String policyText) { + return wasm.callExport(wasm.exports()::cedarParsePolicy, policyText); + } + + public String parseTemplate(String templateText) { + return wasm.callExport(wasm.exports()::cedarParseTemplate, templateText); + } + + public String policyEffect(String policyText) { + return wasm.callExport(wasm.exports()::cedarPolicyEffect, policyText); + } + + public String templateEffect(String templateText) { + return wasm.callExport(wasm.exports()::cedarTemplateEffect, templateText); + } + + public String policyToJson(String policyText) { + return wasm.callExport(wasm.exports()::cedarPolicyToJson, policyText); + } + + public String policyFromJson(String policyJson) { + return wasm.callExport(wasm.exports()::cedarPolicyFromJson, policyJson); + } + + public String getPolicyAnnotations(String policyText) { + return wasm.callExport(wasm.exports()::cedarGetPolicyAnnotations, policyText); + } + + public String getTemplateAnnotations(String templateText) { + return wasm.callExport(wasm.exports()::cedarGetTemplateAnnotations, templateText); + } + + public String parsePolicies(String policiesText) { + return wasm.callExport(wasm.exports()::cedarParsePolicies, policiesText); + } + + public String policySetToJson(String policySetJson) { + return wasm.callExport(wasm.exports()::cedarPolicySetToJson, policySetJson); + } + + public String formatPolicies(String policiesText) { + return wasm.callExport(wasm.exports()::cedarFormatPolicies, policiesText); + } + + public String parseJsonSchema(String schemaJson) { + return wasm.callExport(wasm.exports()::cedarParseJsonSchema, schemaJson); + } + + public String parseCedarSchema(String schemaText) { + return wasm.callExport(wasm.exports()::cedarParseCedarSchema, schemaText); + } + + public String schemaToCedar(String schemaJson) { + return wasm.callExport(wasm.exports()::cedarSchemaToCedar, schemaJson); + } + + public String schemaToJson(String cedarSchema) { + return wasm.callExport(wasm.exports()::cedarSchemaToJson, cedarSchema); + } + + public String getVersion() { + int widePtr = wasm.exports().cedarVersion(); + return wasm.readWidePtr(widePtr); + } + + public String preParsePolicySet(String id, String policiesJson) { + return wasm.callExport(wasm.exports()::cedarPreparsePolicySet, id, policiesJson); + } + + public String preParseSchema(String id, String schemaJson) { + return wasm.callExport(wasm.exports()::cedarPreparseSchema, id, schemaJson); + } + + @Override + public void close() {} +} diff --git a/CedarWasm/core/src/main/java/com/cedarpolicy/wasm/CedarWasm.java b/CedarWasm/core/src/main/java/com/cedarpolicy/wasm/CedarWasm.java new file mode 100644 index 00000000..7644c013 --- /dev/null +++ b/CedarWasm/core/src/main/java/com/cedarpolicy/wasm/CedarWasm.java @@ -0,0 +1,129 @@ +package com.cedarpolicy.wasm; + +import com.dylibso.chicory.runtime.Memory; +import java.nio.charset.StandardCharsets; + +public class CedarWasm { + private final CedarWasmExports_ModuleExports exports; + private final Memory memory; + + private static final int BUF_SIZE = 256 * 1024; + private int inputBufPtr; + private int inputBufCap; + private int outputBufPtr; + private int outputBufCap; + + private CedarWasm(CedarWasmExports_ModuleExports exports) { + this.exports = exports; + this.memory = exports.memory(); + this.inputBufCap = BUF_SIZE; + this.inputBufPtr = exports.alloc(inputBufCap); + this.outputBufCap = BUF_SIZE; + this.outputBufPtr = exports.alloc(outputBufCap); + } + + public static CedarWasm create() { + var instance = CedarWasmModule.builder().build().instance(); + return new CedarWasm(new CedarWasmExports_ModuleExports(instance)); + } + + public CedarWasmExports_ModuleExports exports() { + return exports; + } + + public String authorize(String input) { + return callBuf(exports::cedarAuthorizeBuf, input); + } + + public String statefulAuthorize(String input) { + return callBuf(exports::cedarStatefulAuthorizeBuf, input); + } + + public String call(String operation, String input) { + byte[] opBytes = operation.getBytes(StandardCharsets.UTF_8); + byte[] inputBytes = input.getBytes(StandardCharsets.UTF_8); + int opPtr = allocAndWrite(opBytes); + int inputPtr = allocAndWrite(inputBytes); + int widePtr = exports.cedarCall(opPtr, opBytes.length, inputPtr, inputBytes.length); + exports.dealloc(opPtr, opBytes.length); + exports.dealloc(inputPtr, inputBytes.length); + return readWidePtr(widePtr); + } + + public String callExport(ExportFn fn, String input) { + byte[] inputBytes = input.getBytes(StandardCharsets.UTF_8); + int inputPtr = allocAndWrite(inputBytes); + int widePtr = fn.apply(inputPtr, inputBytes.length); + exports.dealloc(inputPtr, inputBytes.length); + return readWidePtr(widePtr); + } + + public String callExport(ExportFn2 fn, String arg1, String arg2) { + byte[] a1 = arg1.getBytes(StandardCharsets.UTF_8); + byte[] a2 = arg2.getBytes(StandardCharsets.UTF_8); + int p1 = allocAndWrite(a1); + int p2 = allocAndWrite(a2); + int widePtr = fn.apply(p1, a1.length, p2, a2.length); + exports.dealloc(p1, a1.length); + exports.dealloc(p2, a2.length); + return readWidePtr(widePtr); + } + + // ── Internal ────────────────────────────────────────────────────── + + private String callBuf(BufFn fn, String input) { + byte[] inputBytes = input.getBytes(StandardCharsets.UTF_8); + ensureBuf(inputBytes.length); + memory.write(inputBufPtr, inputBytes); + int written = fn.apply(inputBufPtr, inputBytes.length, outputBufPtr, outputBufCap); + if (written < 0) { + growOutputBuf(-written); + memory.write(inputBufPtr, inputBytes); + written = fn.apply(inputBufPtr, inputBytes.length, outputBufPtr, outputBufCap); + } + return new String(memory.readBytes(outputBufPtr, written), StandardCharsets.UTF_8); + } + + private void ensureBuf(int needed) { + if (needed <= inputBufCap) return; + exports.dealloc(inputBufPtr, inputBufCap); + inputBufCap = needed * 2; + inputBufPtr = exports.alloc(inputBufCap); + } + + private void growOutputBuf(int needed) { + exports.dealloc(outputBufPtr, outputBufCap); + outputBufCap = needed * 2; + outputBufPtr = exports.alloc(outputBufCap); + } + + private int allocAndWrite(byte[] data) { + int ptr = exports.alloc(data.length); + memory.write(ptr, data); + return ptr; + } + + String readWidePtr(int widePtr) { + int dataPtr = memory.readInt(widePtr); + int dataLen = memory.readInt(widePtr + 4); + byte[] bytes = memory.readBytes(dataPtr, dataLen); + exports.dealloc(dataPtr, dataLen); + exports.dealloc(widePtr, 8); + return new String(bytes, StandardCharsets.UTF_8); + } + + @FunctionalInterface + public interface ExportFn { + int apply(int ptr, int len); + } + + @FunctionalInterface + public interface ExportFn2 { + int apply(int p1, int l1, int p2, int l2); + } + + @FunctionalInterface + private interface BufFn { + int apply(int inPtr, int inLen, int outPtr, int outCap); + } +} diff --git a/CedarWasm/core/src/test/java/com/cedarpolicy/wasm/CedarEngineTest.java b/CedarWasm/core/src/test/java/com/cedarpolicy/wasm/CedarEngineTest.java new file mode 100644 index 00000000..9372fdde --- /dev/null +++ b/CedarWasm/core/src/test/java/com/cedarpolicy/wasm/CedarEngineTest.java @@ -0,0 +1,217 @@ +package com.cedarpolicy.wasm; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class CedarEngineTest { + + private static CedarEngine engine; + + @BeforeAll + static void setUp() { + engine = CedarEngine.create(); + } + + @Test + void cedarVersion() { + assertEquals("4.0", engine.getVersion()); + } + + @Test + void simplePermit() { + String request = """ + { + "principal": {"type": "User", "id": "alice"}, + "action": {"type": "Action", "id": "view"}, + "resource": {"type": "Resource", "id": "doc1"}, + "context": {}, + "policies": { + "staticPolicies": {"p0": "permit(principal,action,resource);"}, + "templates": {}, + "templateLinks": [] + }, + "entities": [] + } + """; + String result = engine.authorize(request); + assertTrue(result.contains("\"decision\":\"allow\""), "Expected allow, got: " + result); + } + + @Test + void simpleForbid() { + String request = """ + { + "principal": {"type": "User", "id": "alice"}, + "action": {"type": "Action", "id": "view"}, + "resource": {"type": "Resource", "id": "doc1"}, + "context": {}, + "policies": { + "staticPolicies": {"p0": "forbid(principal,action,resource);"}, + "templates": {}, + "templateLinks": [] + }, + "entities": [] + } + """; + String result = engine.authorize(request); + assertTrue(result.contains("\"decision\":\"deny\""), "Expected deny, got: " + result); + } + + @Test + void parsePolicy() { + String result = engine.parsePolicy("permit(principal,action,resource);"); + assertFalse(result.startsWith("ERROR:"), "Parse failed: " + result); + assertTrue(result.contains("permit"), "Expected permit in parsed output: " + result); + } + + @Test + void parsePolicyInvalid() { + String result = engine.parsePolicy("not a valid policy"); + assertTrue(result.startsWith("ERROR:"), "Expected error for invalid policy"); + } + + @Test + void policyEffect() { + assertEquals("permit", engine.policyEffect("permit(principal,action,resource);")); + assertEquals("forbid", engine.policyEffect("forbid(principal,action,resource);")); + } + + @Test + void parseTemplate() { + String result = engine.parseTemplate( + "permit(principal==?principal,action,resource==?resource);"); + assertFalse(result.startsWith("ERROR:"), "Parse failed: " + result); + } + + @Test + void templateEffect() { + assertEquals("permit", + engine.templateEffect("permit(principal==?principal,action,resource==?resource);")); + } + + @Test + void policyToJsonAndBack() { + String policyText = "permit(principal,action,resource);"; + String json = engine.policyToJson(policyText); + assertFalse(json.startsWith("ERROR:"), "toJson failed: " + json); + + String roundTripped = engine.policyFromJson(json); + assertFalse(roundTripped.startsWith("ERROR:"), "fromJson failed: " + roundTripped); + assertTrue(roundTripped.contains("permit")); + } + + @Test + void policyAnnotations() { + String policy = "@id(\"myPolicy\") @myKey(\"myValue\") permit(principal,action,resource);"; + String annotations = engine.getPolicyAnnotations(policy); + assertFalse(annotations.startsWith("ERROR:"), "annotations failed: " + annotations); + assertTrue(annotations.contains("\"id\""), "Expected 'id' annotation"); + assertTrue(annotations.contains("\"myPolicy\""), "Expected 'myPolicy' value"); + assertTrue(annotations.contains("\"myKey\""), "Expected 'myKey' annotation"); + } + + @Test + void parseJsonSchema() { + String schema = """ + { + "": { + "entityTypes": { + "User": {"memberOfTypes": ["Group"]}, + "Group": {}, + "File": {} + }, + "actions": { + "read": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["File"] + } + } + } + } + } + """; + String result = engine.parseJsonSchema(schema); + assertEquals("success", result); + } + + @Test + void parseCedarSchema() { + String schema = """ + entity User = { name: String }; + entity Photo; + action view appliesTo { + principal: [User], + resource: [Photo] + }; + """; + String result = engine.parseCedarSchema(schema); + assertEquals("success", result); + } + + @Test + void formatPolicies() { + String result = engine.formatPolicies("permit(principal,action,resource);"); + assertFalse(result.startsWith("ERROR:"), "format failed: " + result); + assertTrue(result.contains("permit")); + } + + @Test + void validatePolicies() { + String request = """ + { + "schema": { + "": { + "entityTypes": { + "User": {}, + "Photo": {} + }, + "actions": { + "viewPhoto": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Photo"] + } + } + } + } + }, + "policies": { + "staticPolicies": { + "p0": "permit(principal == User::\\"alice\\", action == Action::\\"viewPhoto\\", resource);" + }, + "templates": {}, + "templateLinks": [] + } + } + """; + String result = engine.validate(request); + assertNotNull(result); + assertFalse(result.isEmpty()); + } + + @Test + void cachedAuthorization() { + String policiesJson = """ + {"staticPolicies": {"p0": "permit(principal,action,resource);"}, "templates": {}, "templateLinks": []} + """; + String parseResult = engine.preParsePolicySet("test-policies", policiesJson); + assertFalse(parseResult.contains("errors"), "preparse failed: " + parseResult); + + String request = """ + { + "principal": {"type": "User", "id": "alice"}, + "action": {"type": "Action", "id": "view"}, + "resource": {"type": "Resource", "id": "doc1"}, + "context": {}, + "preparsedPolicySetId": "test-policies", + "validateRequest": false, + "entities": [] + } + """; + String result = engine.statefulAuthorize(request); + assertTrue(result.contains("\"decision\":\"allow\""), "Expected allow, got: " + result); + } +} diff --git a/CedarWasm/core/wasm/cedar_wasm.wasm b/CedarWasm/core/wasm/cedar_wasm.wasm new file mode 100644 index 00000000..3a813f2a Binary files /dev/null and b/CedarWasm/core/wasm/cedar_wasm.wasm differ diff --git a/CedarWasm/pom.xml b/CedarWasm/pom.xml new file mode 100644 index 00000000..bb61d084 --- /dev/null +++ b/CedarWasm/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + com.cedarpolicy + cedar-wasm-parent + 4.0.0-SNAPSHOT + pom + Cedar Wasm for Java + Evaluate Cedar policies via Wasm using the Chicory Redline runtime + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + + core + benchmark + + + + 17 + 17 + 17 + 3.15.0 + 3.5.5 + UTF-8 + 5.14.3 + 0.0.4 + 1.37 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${maven.compiler.source} + ${maven.compiler.target} + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + + diff --git a/CedarWasm/wasm-build/Cargo.toml b/CedarWasm/wasm-build/Cargo.toml new file mode 100644 index 00000000..156fbea3 --- /dev/null +++ b/CedarWasm/wasm-build/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "cedar-wasm" +license = "Apache-2.0" +description = "Cedar policy engine compiled to WebAssembly." +edition = "2021" +version = "4.0.0" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +miette = "7.6.0" +itertools = "0.14" + +[features] +partial-eval = ["cedar-policy/partial-eval"] + +[lib] +crate-type = ["cdylib"] + +[dependencies.cedar-policy] +version = "4.0.0" +git = "https://github.com/cedar-policy/cedar" +branch = "main" + +[dependencies.cedar-policy-formatter] +version = "4.0.0" +git = "https://github.com/cedar-policy/cedar" +branch = "main" + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/CedarWasm/wasm-build/rust-toolchain.toml b/CedarWasm/wasm-build/rust-toolchain.toml new file mode 100644 index 00000000..d0fee7c9 --- /dev/null +++ b/CedarWasm/wasm-build/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.89" +targets = ["wasm32-unknown-unknown"] diff --git a/CedarWasm/wasm-build/src/lib.rs b/CedarWasm/wasm-build/src/lib.rs new file mode 100644 index 00000000..fe67ff94 --- /dev/null +++ b/CedarWasm/wasm-build/src/lib.rs @@ -0,0 +1,592 @@ +use cedar_policy::entities_errors::EntitiesError; +#[cfg(feature = "partial-eval")] +use cedar_policy::ffi::is_authorized_partial_json_str; +use cedar_policy::ffi::{ + is_authorized_json_str, preparse_policy_set, preparse_schema, + schema_to_json, schema_to_text, stateful_is_authorized, validate_json_str, + CheckParseAnswer, PolicySet as PolicySetFFI, Schema as FFISchema, + SchemaToJsonAnswer, SchemaToTextAnswer, StatefulAuthorizationCall, +}; +use cedar_policy::{Entities as CedarEntities, Policy, PolicySet, Schema, Template}; +use cedar_policy_formatter::{policies_str_to_pretty, Config}; +use serde::{Deserialize, Serialize}; +use serde_json::{from_str, Value}; +use std::alloc::Layout; +use std::str::FromStr; + +// ── Memory management ────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn alloc(size: usize) -> *mut u8 { + if size == 0 { + return std::ptr::null_mut(); + } + let layout = Layout::from_size_align(size, 1).unwrap(); + unsafe { std::alloc::alloc(layout) } +} + +#[no_mangle] +pub unsafe extern "C" fn dealloc(ptr: *mut u8, size: usize) { + if size > 0 && !ptr.is_null() { + let layout = Layout::from_size_align(size, 1).unwrap(); + std::alloc::dealloc(ptr, layout); + } +} + +// ── Wide pointer helpers ─────────────────────────────────────────────── + +fn return_string(s: &str) -> *const u32 { + return_bytes(s.as_bytes()) +} + +fn return_bytes(data: &[u8]) -> *const u32 { + let len = data.len(); + let data_ptr = alloc(len); + unsafe { + core::ptr::copy_nonoverlapping(data.as_ptr(), data_ptr, len); + let wide = alloc(8) as *mut u32; + core::ptr::write(wide, data_ptr as u32); + core::ptr::write(wide.add(1), len as u32); + wide as *const u32 + } +} + +fn read_str<'a>(ptr: *const u8, len: usize) -> &'a str { + unsafe { core::str::from_utf8_unchecked(core::slice::from_raw_parts(ptr, len)) } +} + +// ── Answer type (mirrors CedarJavaFFI/src/answer.rs) ─────────────────── + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "success")] +enum Answer { + #[serde(rename = "true")] + Success { result: String }, + #[serde(rename = "false")] + Failure { + #[serde(rename = "isInternal")] + is_internal: bool, + errors: Vec, + }, +} + +impl Answer { + fn fail_internally(message: String) -> Self { + Self::Failure { + is_internal: true, + errors: vec![message], + } + } + + fn fail_bad_request(errors: Vec) -> Self { + Self::Failure { + is_internal: false, + errors, + } + } +} + +// ── Validate entities ────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +struct ValidateEntityCall { + schema: Value, + entities: Value, +} + +fn json_validate_entities(input: &str) -> serde_json::Result { + let ans = validate_entities(input)?; + serde_json::to_string(&ans) +} + +fn validate_entities(input: &str) -> serde_json::Result { + let call = from_str::(input)?; + let schema = match call.schema { + Value::String(cedarschema_str) => match Schema::from_cedarschema_str(&cedarschema_str) { + Ok(s) => s.0, + Err(e) => return Ok(Answer::fail_bad_request(vec![e.to_string()])), + }, + cedarschema_json_obj => match Schema::from_json_value(cedarschema_json_obj) { + Ok(s) => s, + Err(e) => return Ok(Answer::fail_bad_request(vec![e.to_string()])), + }, + }; + match CedarEntities::from_json_value(call.entities, Some(&schema)) { + Err(error) => { + let err_message = match error { + EntitiesError::Serialization(err) => err.to_string(), + EntitiesError::Deserialization(err) => err.to_string(), + EntitiesError::Duplicate(err) => err.to_string(), + EntitiesError::TransitiveClosureError(err) => err.to_string(), + EntitiesError::InvalidEntity(err) => err.to_string(), + }; + Ok(Answer::fail_bad_request(vec![err_message])) + } + Ok(_) => Ok(Answer::Success { + result: "null".to_string(), + }), + } +} + +// ── Validate with level (helpers.rs port) ────────────────────────────── + +use cedar_policy::ffi::{ + JsonValueWithNoDuplicateKeys, PolicySet as FFIPolicies, ValidationAnswer, ValidationError, +}; +use cedar_policy::{SchemaFragment, SchemaWarning, ValidationMode, Validator}; + +#[derive(Serialize, Deserialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct ValidationSettings { + mode: ValidationMode, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct LevelValidationCall { + #[serde(default)] + validation_settings: ValidationSettings, + schema: FFISchemaLocal, + policies: FFIPolicies, + max_deref_level: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum FFISchemaLocal { + Cedar(String), + Json(JsonValueWithNoDuplicateKeys), +} + +impl FFISchemaLocal { + fn parse(self) -> Result<(Schema, Box>), miette::Report> { + use miette::Context; + let (frag, warnings) = match self { + Self::Cedar(s) => { + let (frag, w) = SchemaFragment::from_cedarschema_str(&s) + .wrap_err("failed to parse schema from string")?; + ( + frag, + Box::new(w) as Box>, + ) + } + Self::Json(val) => { + let frag = SchemaFragment::from_json_value(val.into()) + .wrap_err("failed to parse schema from JSON")?; + ( + frag, + Box::new(std::iter::empty()) as Box>, + ) + } + }; + Ok((frag.try_into()?, warnings)) + } +} + +fn validate_with_level_json_str(json: &str) -> Result { + let call: LevelValidationCall = serde_json::from_str(json)?; + let ans = validate_with_level(call); + serde_json::to_string(&ans) +} + +fn validate_with_level(call: LevelValidationCall) -> ValidationAnswer { + let mut errs = vec![]; + let policies = match call.policies.parse() { + Ok(p) => p, + Err(e) => { + errs.extend(e); + PolicySet::new() + } + }; + let pair = match call.schema.parse() { + Ok((schema, warnings)) => Some((schema, warnings)), + Err(e) => { + errs.push(e); + None + } + }; + match (errs.is_empty(), pair) { + (true, Some((schema, warnings))) => { + let validator = Validator::new(schema); + let result = + validator.validate_with_level(&policies, call.validation_settings.mode, call.max_deref_level); + let validation_errors: Vec = result + .validation_errors() + .map(|e| ValidationError { + policy_id: e.policy_id().clone(), + error: miette::Report::new(e.clone()).into(), + }) + .collect(); + let validation_warnings: Vec = result + .validation_warnings() + .map(|e| ValidationError { + policy_id: e.policy_id().clone(), + error: miette::Report::new(e.clone()).into(), + }) + .collect(); + ValidationAnswer::Success { + validation_errors, + validation_warnings, + other_warnings: warnings.map(|w| miette::Report::new(w).into()).collect(), + } + } + _ => ValidationAnswer::Failure { + errors: errs.into_iter().map(Into::into).collect(), + warnings: vec![], + }, + } +} + +// ── Main dispatcher ──────────────────────────────────────────────────── + +const V0_AUTH_OP: &str = "AuthorizationOperation"; +#[cfg(feature = "partial-eval")] +const V0_AUTH_PARTIAL_OP: &str = "AuthorizationPartialOperation"; +const V0_VALIDATE_OP: &str = "ValidateOperation"; +const V0_VALIDATE_LEVEL_OP: &str = "ValidateWithLevelOperation"; +const V0_VALIDATE_ENTITIES: &str = "ValidateEntities"; +const V0_STATEFUL_AUTH_OP: &str = "StatefulAuthorizationOperation"; + +fn stateful_is_authorized_json_str(json: &str) -> Result { + let call: StatefulAuthorizationCall = serde_json::from_str(json)?; + let ans = stateful_is_authorized(call); + serde_json::to_string(&ans) +} + +fn call_cedar(call: &str, input: &str) -> String { + let result = match call { + V0_AUTH_OP => is_authorized_json_str(input), + #[cfg(feature = "partial-eval")] + V0_AUTH_PARTIAL_OP => is_authorized_partial_json_str(input), + V0_VALIDATE_OP => validate_json_str(input), + V0_VALIDATE_ENTITIES => json_validate_entities(input), + V0_VALIDATE_LEVEL_OP => validate_with_level_json_str(input), + V0_STATEFUL_AUTH_OP => stateful_is_authorized_json_str(input), + _ => { + let ires = Answer::fail_internally(format!("unsupported operation: {}", call)); + serde_json::to_string(&ires) + } + }; + result.unwrap_or_else(|err| { + panic!("failed to handle call {call} with input {input}\nError: {err}") + }) +} + +#[no_mangle] +pub extern "C" fn cedar_call( + op_ptr: *const u8, + op_len: usize, + input_ptr: *const u8, + input_len: usize, +) -> *const u32 { + let op = read_str(op_ptr, op_len); + let input = read_str(input_ptr, input_len); + let result = call_cedar(op, input); + return_string(&result) +} + +// ── Version ──────────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cedar_version() -> *const u32 { + return_string("4.0") +} + +// ── Policy utilities ─────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cedar_parse_policy(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Policy::from_str(input) { + Ok(p) => return_string(&p.to_string()), + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_parse_template(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Template::from_str(input) { + Ok(t) => return_string(&t.to_string()), + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_policy_effect(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Policy::from_str(input) { + Ok(p) => return_string(&p.effect().to_string()), + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_template_effect(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Template::from_str(input) { + Ok(t) => return_string(&t.effect().to_string()), + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_policy_to_json(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Policy::from_str(input) { + Ok(p) => match serde_json::to_string(&p.to_json().unwrap()) { + Ok(json) => return_string(&json), + Err(e) => return_string(&format!("ERROR:{}", e)), + }, + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_policy_from_json(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match serde_json::from_str::(input) { + Ok(json_val) => match Policy::from_json(None, json_val) { + Ok(p) => return_string(&p.to_string()), + Err(e) => return_string(&format!("ERROR:{}", e)), + }, + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_get_policy_annotations(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Policy::from_str(input) { + Ok(p) => { + let map: std::collections::HashMap<&str, &str> = p.annotations().collect(); + match serde_json::to_string(&map) { + Ok(json) => return_string(&json), + Err(e) => return_string(&format!("ERROR:{}", e)), + } + } + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_get_template_annotations(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Template::from_str(input) { + Ok(t) => { + let map: std::collections::HashMap<&str, &str> = t.annotations().collect(); + match serde_json::to_string(&map) { + Ok(json) => return_string(&json), + Err(e) => return_string(&format!("ERROR:{}", e)), + } + } + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_parse_policies(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match PolicySet::from_str(input) { + Ok(ps) => { + let policies: Vec = ps + .policies() + .map(|p| { + serde_json::json!({ + "id": p.id().to_string(), + "text": p.to_string(), + }) + }) + .collect(); + let templates: Vec = ps + .templates() + .map(|t| { + serde_json::json!({ + "id": t.id().to_string(), + "text": t.to_string(), + }) + }) + .collect(); + let result = serde_json::json!({ + "policies": policies, + "templates": templates, + }); + return_string(&result.to_string()) + } + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_policy_set_to_json(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match serde_json::from_str::(input) { + Ok(ffi_ps) => match ffi_ps.parse() { + Ok(ps) => match serde_json::to_string(&ps.to_json().unwrap()) { + Ok(json) => return_string(&json), + Err(e) => return_string(&format!("ERROR:{}", e)), + }, + Err(errs) => { + let msg = errs + .into_iter() + .map(|e| e.to_string()) + .collect::>() + .join("; "); + return_string(&format!("ERROR:{}", msg)) + } + }, + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_format_policies(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match policies_str_to_pretty(input, &Config::default()) { + Ok(formatted) => return_string(&formatted), + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +// ── Schema utilities ─────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cedar_parse_json_schema(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Schema::from_json_str(input) { + Ok(_) => return_string("success"), + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_parse_cedar_schema(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match Schema::from_cedarschema_str(input) { + Ok(_) => return_string("success"), + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_schema_to_cedar(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match serde_json::from_str::(input) { + Ok(schema) => match schema_to_text(schema) { + SchemaToTextAnswer::Success { text, .. } => return_string(&text), + SchemaToTextAnswer::Failure { errors } => { + let msg = errors.iter().map(|e| e.message.clone()).collect::>().join("; "); + return_string(&format!("ERROR:{}", msg)) + } + }, + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_schema_to_json(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + let cedar_schema = FFISchema::Cedar(input.to_string()); + match schema_to_json(cedar_schema) { + SchemaToJsonAnswer::Success { json, .. } => { + match serde_json::to_string_pretty(&json) { + Ok(s) => return_string(&s), + Err(e) => return_string(&format!("ERROR:{}", e)), + } + } + SchemaToJsonAnswer::Failure { errors } => { + let msg = errors.iter().map(|e| e.message.clone()).collect::>().join("; "); + return_string(&format!("ERROR:{}", msg)) + } + } +} + +// ── Caching ──────────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cedar_preparse_policy_set( + id_ptr: *const u8, id_len: usize, + json_ptr: *const u8, json_len: usize, +) -> *const u32 { + let id = read_str(id_ptr, id_len).to_string(); + let json = read_str(json_ptr, json_len); + match serde_json::from_str::(json) { + Ok(policies) => { + let ans = preparse_policy_set(id, policies); + return_string(&serde_json::to_string(&ans).unwrap()) + } + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_preparse_schema( + id_ptr: *const u8, id_len: usize, + json_ptr: *const u8, json_len: usize, +) -> *const u32 { + let id = read_str(id_ptr, id_len).to_string(); + let json = read_str(json_ptr, json_len); + match serde_json::from_str::(json) { + Ok(schema) => { + let ans = preparse_schema(id, schema); + return_string(&serde_json::to_string(&ans).unwrap()) + } + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +#[no_mangle] +pub extern "C" fn cedar_stateful_authorize(ptr: *const u8, len: usize) -> *const u32 { + let input = read_str(ptr, len); + match stateful_is_authorized_json_str(input) { + Ok(result) => return_string(&result), + Err(e) => return_string(&format!("ERROR:{}", e)), + } +} + +/// Single-crossing stateful authorization. +/// Input is already in Wasm memory at `input_ptr` (written by host via memory.write). +/// Result is written to `out_ptr` (a host-provided buffer). +/// Returns the number of bytes written to out_ptr, or negative on overflow. +#[no_mangle] +pub extern "C" fn cedar_stateful_authorize_buf( + input_ptr: *const u8, input_len: usize, + out_ptr: *mut u8, out_cap: usize, +) -> i32 { + let input = read_str(input_ptr, input_len); + let result = match stateful_is_authorized_json_str(input) { + Ok(r) => r, + Err(e) => format!("ERROR:{}", e), + }; + let result_bytes = result.as_bytes(); + if result_bytes.len() > out_cap { + return -(result_bytes.len() as i32); + } + unsafe { + core::ptr::copy_nonoverlapping(result_bytes.as_ptr(), out_ptr, result_bytes.len()); + } + result_bytes.len() as i32 +} + +/// Same for regular (non-cached) authorization. +#[no_mangle] +pub extern "C" fn cedar_authorize_buf( + input_ptr: *const u8, input_len: usize, + out_ptr: *mut u8, out_cap: usize, +) -> i32 { + let input = read_str(input_ptr, input_len); + let result = match is_authorized_json_str(input) { + Ok(r) => r, + Err(e) => format!("ERROR:{}", e), + }; + let result_bytes = result.as_bytes(); + if result_bytes.len() > out_cap { + return -(result_bytes.len() as i32); + } + unsafe { + core::ptr::copy_nonoverlapping(result_bytes.as_ptr(), out_ptr, result_bytes.len()); + } + result_bytes.len() as i32 +}