From 873f29f39cb15b07d5c0cd5e21aceb40073c59d1 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 18 May 2026 09:48:41 +0100 Subject: [PATCH 01/20] feat(spring-data): add Spring Data JPA query plan adapter Translates Cerbos PlanResources responses into Spring Data JPA Specifications, mirroring the operator coverage of the Prisma adapter (eq/ne/lt/gt/le/ge, in, contains/startsWith/endsWith, isSet, hasIntersection, size, exists/exists_one/all/except/filter lambdas, and add with constant folding/solving). Supports @OneToMany, @ManyToMany, @ManyToOne, @ElementCollection and deeply nested relations via correlated subqueries. Includes a full e2e test suite that runs against a real Cerbos PDP container (Testcontainers by default, or an externally-managed container via CERBOS_HOST/CERBOS_PORT + docker-compose), with audit logs streamed to stdout so PlanResources calls are verifiable. Published as 0.1.0-alpha.1 to invite feedback on the field/relation mapping shapes before 1.0. Signed-off-by: Alex Olivier --- .github/workflows/spring-data.yaml | 36 + CLAUDE.md | 10 +- spring-data/.gitignore | 4 + spring-data/Dockerfile | 5 + spring-data/README.md | 203 ++++ spring-data/build.gradle.kts | 50 + spring-data/cerbos-config.yaml | 26 + spring-data/docker-compose.yml | 38 + spring-data/scripts/run-e2e.sh | 76 ++ spring-data/settings.gradle.kts | 1 + .../springdata/AttributeMapping.java | 31 + .../springdata/OperatorFunction.java | 14 + .../cerbos/queryplan/springdata/Result.java | 40 + .../SpringDataQueryPlanAdapter.java | 896 ++++++++++++++++++ .../springdata/SpringDataIntegrationTest.java | 886 +++++++++++++++++ .../SpringDataQueryPlanAdapterTest.java | 417 ++++++++ .../springdata/testmodel/CategoryEntity.java | 47 + .../springdata/testmodel/LabelEntity.java | 37 + .../testmodel/NestedEmbeddable.java | 37 + .../testmodel/NextLevelEmbeddable.java | 21 + .../springdata/testmodel/OwnerEntity.java | 39 + .../springdata/testmodel/ResourceEntity.java | 110 +++ .../testmodel/SubCategoryEntity.java | 47 + .../springdata/testmodel/TagEntity.java | 46 + .../test/resources/META-INF/persistence.xml | 29 + 25 files changed, 3143 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/spring-data.yaml create mode 100644 spring-data/.gitignore create mode 100644 spring-data/Dockerfile create mode 100644 spring-data/README.md create mode 100644 spring-data/build.gradle.kts create mode 100644 spring-data/cerbos-config.yaml create mode 100644 spring-data/docker-compose.yml create mode 100755 spring-data/scripts/run-e2e.sh create mode 100644 spring-data/settings.gradle.kts create mode 100644 spring-data/src/main/java/dev/cerbos/queryplan/springdata/AttributeMapping.java create mode 100644 spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java create mode 100644 spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java create mode 100644 spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/CategoryEntity.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/LabelEntity.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NestedEmbeddable.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NextLevelEmbeddable.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/OwnerEntity.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/SubCategoryEntity.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/TagEntity.java create mode 100644 spring-data/src/test/resources/META-INF/persistence.xml diff --git a/.github/workflows/spring-data.yaml b/.github/workflows/spring-data.yaml new file mode 100644 index 00000000..afe01e37 --- /dev/null +++ b/.github/workflows/spring-data.yaml @@ -0,0 +1,36 @@ +name: Spring Data Test + +on: + pull_request: + paths: + - "spring-data/**" + - "policies/**" + - ".github/workflows/spring-data.yaml" + push: + tags: + - spring-data/v* + +defaults: + run: + working-directory: spring-data + +jobs: + test: + strategy: + matrix: + java-version: ["17", "21"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + + - name: Build and test + run: gradle build --no-daemon diff --git a/CLAUDE.md b/CLAUDE.md index bf197a85..0741037c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ Multi-language ORM adapters that translate Cerbos query plan responses into data | langchain-chromadb | TypeScript | `@cerbos/langchain-chromadb` | ChromaDB | | sqlalchemy | Python | `cerbos-sqlalchemy` | SQLAlchemy | | elasticsearch-java | Java | `cerbos-elasticsearch` | Elasticsearch | +| spring-data | Java | `cerbos-spring-data` | Spring Data JPA | ## Commands @@ -34,9 +35,12 @@ pdm run test # pytest pdm run format # isort + black ``` -### Java (Elasticsearch) +### Java (Elasticsearch, Spring Data) ```bash -docker run --rm -v "$(pwd)":/app -w /app gradle:8.12-jdk17 gradle build --no-daemon +# For tests that use testcontainers (cerbos PDP + DBs), mount the docker socket: +docker run --rm -v "$(pwd)":/app -v /var/run/docker.sock:/var/run/docker.sock \ + -e TESTCONTAINERS_RYUK_DISABLED=true --network host \ + -w /app gradle:8.12-jdk17 gradle build --no-daemon ``` ## Testing @@ -68,7 +72,7 @@ Conventional Commits: `feat(prisma):`, `fix(mongoose):`, `chore(deps):`. Scope i Each adapter has its own GitHub Actions workflow triggered by changes in its directory or `/policies/`. Matrix tests across Node versions (20, 22, 24, 25) and relevant service versions. -Tag-based publishing: `prisma/v*` -> npm, `sqla/v*` -> PyPI, `elasticsearch-java/v*` -> Maven Central. +Tag-based publishing: `prisma/v*` -> npm, `sqla/v*` -> PyPI, `elasticsearch-java/v*` and `spring-data/v*` -> Maven Central. ## Working with Adapters diff --git a/spring-data/.gitignore b/spring-data/.gitignore new file mode 100644 index 00000000..e81a89f7 --- /dev/null +++ b/spring-data/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +.idea/ +*.iml diff --git a/spring-data/Dockerfile b/spring-data/Dockerfile new file mode 100644 index 00000000..0bce1cdf --- /dev/null +++ b/spring-data/Dockerfile @@ -0,0 +1,5 @@ +FROM gradle:8.12-jdk17 AS build +WORKDIR /app +COPY build.gradle.kts settings.gradle.kts ./ +COPY src ./src +RUN gradle build --no-daemon diff --git a/spring-data/README.md b/spring-data/README.md new file mode 100644 index 00000000..2a3b6a44 --- /dev/null +++ b/spring-data/README.md @@ -0,0 +1,203 @@ +# cerbos-spring-data + +> **Alpha release — `0.1.0-alpha.1`.** API and operator coverage are stable, but field/relation +> mapping shapes may still change before `1.0`. We'd love feedback while it's still alpha. + +[Cerbos](https://cerbos.dev) query plan adapter for [Spring Data JPA](https://spring.io/projects/spring-data-jpa). Converts a Cerbos `PlanResources` response into a `org.springframework.data.jpa.domain.Specification` you can pass straight to a `JpaSpecificationExecutor`. + +## Install + +Gradle: + +```kotlin +dependencies { + implementation("dev.cerbos:cerbos-spring-data:0.1.0-alpha.1") +} +``` + +Maven: + +```xml + + dev.cerbos + cerbos-spring-data + 0.1.0-alpha.1 + +``` + +You'll also need the Cerbos Java SDK (`dev.cerbos:cerbos-sdk-java`) to call the PDP and Spring Data JPA (`org.springframework.data:spring-data-jpa`). + +## Quick start + +```java +import dev.cerbos.queryplan.springdata.AttributeMapping; +import dev.cerbos.queryplan.springdata.Result; +import dev.cerbos.queryplan.springdata.SpringDataQueryPlanAdapter; +import dev.cerbos.sdk.CerbosBlockingClient; +import dev.cerbos.sdk.builders.Principal; +import dev.cerbos.sdk.builders.Resource; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.Map; + +public interface ContactRepository + extends JpaRepository, JpaSpecificationExecutor {} + +// Map Cerbos resource attributes to JPA paths or relations on your entity: +Map MAPPING = Map.of( + "request.resource.attr.ownerId", AttributeMapping.field("owner.id"), + "request.resource.attr.isPublic", AttributeMapping.field("isPublic"), + "request.resource.attr.department", AttributeMapping.field("department"), + "request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "name", AttributeMapping.field("name") + )) +); + +// 1) Call the PDP for a query plan +var planResult = cerbosClient.plan( + Principal.newInstance("alice", "USER"), + Resource.newInstance("contact"), + "view"); + +// 2) Translate to a Specification +Result result = + SpringDataQueryPlanAdapter.toSpecification(planResult, MAPPING); + +// 3) Execute via your repository +List contacts = contactRepository.findAll(result.toSpecification()); +``` + +`Result.toSpecification()` returns a Specification that captures all three plan kinds, so you don't need to switch on the result kind unless you want to short-circuit the DB hit: + +| Kind | Specification | +|------------------------|----------------------------------------| +| `Result.AlwaysAllowed` | always-true predicate (`1=1`) | +| `Result.AlwaysDenied` | always-false predicate (`1=0`) | +| `Result.Conditional` | the translated predicate tree | + +Compose it with your own filters: + +```java +Specification own = + (root, query, cb) -> cb.like(root.get("name"), "Smith%"); + +List results = contactRepository.findAll( + own.and(result.toSpecification()), pageable); +``` + +## Field mapping + +Map each `request.resource.attr.` to a JPA path or a relation: + +| Helper | Use for | +|---------------------------------------------------------------|---------------------------------------------------------| +| `AttributeMapping.field("aPath")` | Simple column or `@Embedded` dotted path | +| `AttributeMapping.relation("tags")` | `@ElementCollection` (bare values) | +| `AttributeMapping.relation("tags", "name")` | `@OneToMany` collection where the default member field is `name` | +| `AttributeMapping.relation("tags", Map.of("name", field("name")))` | `@OneToMany` with explicit nested field mapping | + +`Field("nested.aBool")` traverses embeddables via JPA `Path.get(...)`. Use it for both simple columns and `@Embedded` paths. + +## Supported operators + +| Cerbos operator | JPA Criteria translation | +|----------------------------------|---------------------------------------------------------------------| +| `and` / `or` / `not` | `cb.and` / `cb.or` / `cb.not` | +| `eq` / `ne` | `cb.equal` / `cb.notEqual` (auto `isNull`/`isNotNull` for `null` RHS) | +| `lt` / `gt` / `le` / `ge` | `cb.lessThan` / `greaterThan` / `lessThanOrEqualTo` / `greaterThanOrEqualTo` | +| `in` | `path.in(values)` or correlated `EXISTS` for collections | +| `contains` / `startsWith` / `endsWith` | `cb.like(...)` with proper `_`/`%`/`\` escaping | +| `isSet(field, true/false)` | `cb.isNotNull` / `cb.isNull` | +| `hasIntersection(coll, [values])` | Correlated `EXISTS` with `IN` | +| `hasIntersection(coll.map(x, x.f), [values])` | Correlated `EXISTS` with projected `IN` | +| `size(coll) > 0` / `>= 1` | Correlated `EXISTS` | +| `size(coll) == 0` / `<= 0` / `< 1`| `NOT EXISTS` | +| `exists(coll, lambda)` | Correlated `EXISTS` with lambda body | +| `exists_one(coll, lambda)` | Correlated `(SELECT COUNT...) = 1` | +| `all(coll, lambda)` | `NOT EXISTS (... AND NOT(body))` | +| `except(coll, lambda)` | Correlated `EXISTS (... AND NOT(body))` | +| `filter(coll, lambda)` | Same as `exists` (filter returns a list — treated as "exists matching") | +| Bare boolean variable | `cb.equal(path, true)` | +| `eq(field, add(const1, const2))` | Constant fold then compare: `cb.equal(field, const1 ⊕ const2)` | +| `eq(value, add(const, field))` | Solve for `field` (string prefix/suffix strip; numeric subtract); unsolvable cases become `1=0` / `1=1` | + +Unsupported operators raise `IllegalArgumentException` — override them with `OperatorFunction`: + +```java +Map overrides = Map.of( + "contains", (cb, field, value) -> + cb.equal(cb.lower(field.as(String.class)), value.toString().toLowerCase()) +); + +Result result = + SpringDataQueryPlanAdapter.toSpecification(planResult, MAPPING, overrides); +``` + +## Build + +From the `spring-data/` directory: + +```bash +# With Docker (recommended — matches CI): +docker run --rm -v "$(pwd)/..":/app -v /var/run/docker.sock:/var/run/docker.sock \ + -e TESTCONTAINERS_RYUK_DISABLED=true --network host -w /app/spring-data gradle:8.12-jdk17 \ + gradle build --no-daemon + +# Or with a local Gradle 8.x + JDK 17+: +gradle build --no-daemon +``` + +## End-to-end testing + +Every test runs against a **real Cerbos PDP container** — there is no stubbing of policy +evaluation. Two run modes are supported: + +### 1. Self-managed (default) + +[Testcontainers](https://testcontainers.com) pulls and starts `ghcr.io/cerbos/cerbos:latest`, +mounts `../policies/resource.yaml`, and runs the suite against the gRPC endpoint. The container's +**audit + decision logs are streamed to the test JVM logger** so you can see every +`PlanResources` call the test issued. + +```bash +gradle test +``` + +### 2. Externally-managed (Prisma-style sidecar) + +Matches what the Prisma adapter does with `cerbos run -- jest`: a long-lived PDP container +started separately, tests connect to it via `CERBOS_HOST` / `CERBOS_PORT` env vars. Useful for +debugging the live PDP between test runs. + +```bash +./scripts/run-e2e.sh # docker compose up -d → gradle test → audit log summary → down +``` + +At the end of `run-e2e.sh` you'll see something like: + +``` +==> Cerbos PDP audit summary + PlanResources calls served: 122 + CheckResources calls served: 0 + Audit log archived at: /tmp/cerbos-audit-XXXX.log + +==> Sample decision log entries: +{"log.logger":"cerbos.audit","log.kind":"decision","callId":"01KRX3A0DFF9F00ZC1F0M8Z1MD", + "planResources":{"input":{"actions":["equal-nested"], ...}, + "output":{"filter":{"condition":{...},"kind":"KIND_CONDITIONAL"}, ...}}, ...} +``` + +— this is the PDP's own decision log, proving every assertion in the suite came from a real +policy evaluation against the shared `../policies/resource.yaml`. + +You can also run the compose stack by hand: + +```bash +docker compose up -d +CERBOS_HOST=localhost CERBOS_PORT=3593 gradle test +docker compose down +``` + +When `CERBOS_HOST` is unset, the suite falls back to mode (1) automatically. diff --git a/spring-data/build.gradle.kts b/spring-data/build.gradle.kts new file mode 100644 index 00000000..5cde9e15 --- /dev/null +++ b/spring-data/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + java +} + +group = "dev.cerbos" +version = "0.1.0-alpha.1" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("dev.cerbos:cerbos-sdk-java:0.18.0") + implementation("com.google.protobuf:protobuf-java:4.31.1") + implementation("org.springframework.data:spring-data-jpa:3.5.1") + implementation("jakarta.persistence:jakarta.persistence-api:3.2.0") + + testImplementation(platform("org.junit:junit-bom:5.12.2")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.testcontainers:testcontainers:1.21.3") + testImplementation("org.testcontainers:junit-jupiter:1.21.3") + testImplementation("org.hibernate.orm:hibernate-core:6.6.18.Final") + testImplementation("com.h2database:h2:2.3.232") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly("org.slf4j:slf4j-simple:2.0.17") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = false + } + // Propagate Cerbos PDP connection details to the test JVM so SpringDataIntegrationTest can + // choose between the Testcontainers-managed PDP (default) and an externally-managed one + // (e.g. spawned by docker-compose for CI / `scripts/run-e2e.sh`). + val cerbosHost = System.getenv("CERBOS_HOST") + val cerbosPort = System.getenv("CERBOS_PORT") + if (cerbosHost != null) { + environment("CERBOS_HOST", cerbosHost) + } + if (cerbosPort != null) { + environment("CERBOS_PORT", cerbosPort) + } +} diff --git a/spring-data/cerbos-config.yaml b/spring-data/cerbos-config.yaml new file mode 100644 index 00000000..dde4fd8b --- /dev/null +++ b/spring-data/cerbos-config.yaml @@ -0,0 +1,26 @@ +--- +server: + httpListenAddr: ":3592" + grpcListenAddr: ":3593" + +storage: + driver: "disk" + disk: + directory: /policies + watchForChanges: true + +telemetry: + disabled: true + +schema: + enforcement: reject + +# Stream audit decisions (PlanResources / CheckResources) to stdout as JSON so the e2e run-log +# proves the spring-data adapter is genuinely hitting a live PDP, not a stub. +audit: + enabled: true + accessLogsEnabled: true + decisionLogsEnabled: true + backend: file + file: + path: stdout diff --git a/spring-data/docker-compose.yml b/spring-data/docker-compose.yml new file mode 100644 index 00000000..7d0870ff --- /dev/null +++ b/spring-data/docker-compose.yml @@ -0,0 +1,38 @@ +# Standalone Cerbos PDP for running the spring-data e2e tests against an externally-managed +# container — matches the sidecar pattern the Prisma adapter uses with `cerbos run`. +# +# Usage: +# docker compose up -d # start the PDP +# CERBOS_HOST=localhost CERBOS_PORT=3593 ./gradlew test +# docker compose down # tear it down +# +# Or use the bundled helper: +# ./scripts/run-e2e.sh +# +# When CERBOS_HOST/CERBOS_PORT are NOT set, the test suite falls back to a self-managed +# Testcontainers-spawned PDP (the default for developer machines and CI). + +services: + cerbos: + image: ghcr.io/cerbos/cerbos:latest + container_name: cerbos-spring-data-e2e + command: + - "server" + - "--config=/config/cerbos-config.yaml" + environment: + CERBOS_NO_TELEMETRY: "1" + CERBOS_CONFIG: "/config/cerbos-config.yaml" + ports: + - "3592:3592" + - "3593:3593" + volumes: + - ./cerbos-config.yaml:/config/cerbos-config.yaml:ro + - ../policies:/policies:ro + # Healthcheck uses the cerbos binary's built-in healthcheck command, which reads the same + # config file as the server (CERBOS_CONFIG env var) and probes the gRPC endpoint. + healthcheck: + test: ["CMD", "/cerbos", "healthcheck"] + interval: 2s + timeout: 5s + retries: 30 + start_period: 3s diff --git a/spring-data/scripts/run-e2e.sh b/spring-data/scripts/run-e2e.sh new file mode 100755 index 00000000..f7b6b9d3 --- /dev/null +++ b/spring-data/scripts/run-e2e.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# End-to-end test runner: starts a real Cerbos PDP container via docker compose, waits for it +# to be healthy, then runs the JUnit suite against it. Mirrors what the Prisma adapter does +# with `cerbos run -- jest`. +# +# Exit status mirrors gradle's. The container is torn down on success, failure, or interrupt. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}/.." + +cleanup() { + echo "==> Tearing down Cerbos PDP container" + docker compose down --volumes --remove-orphans >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +echo "==> Starting Cerbos PDP container (ghcr.io/cerbos/cerbos:latest)" +docker compose up -d --wait cerbos + +CERBOS_HOST="${CERBOS_HOST:-localhost}" +CERBOS_PORT="${CERBOS_PORT:-3593}" +export CERBOS_HOST CERBOS_PORT + +echo "==> Cerbos PDP is healthy at ${CERBOS_HOST}:${CERBOS_PORT}" + +# Stream the PDP's audit/decision logs to a file. Decision-log JSON lines have callId/method +# entries that prove each test really called PlanResources against the live PDP. +AUDIT_LOG="$(mktemp -t cerbos-audit-XXXXXX.log)" +docker compose logs -f cerbos --no-color >"${AUDIT_LOG}" 2>&1 & +AUDIT_PID=$! +trap 'cleanup; kill "${AUDIT_PID}" 2>/dev/null || true; rm -f "${AUDIT_LOG}"' EXIT INT TERM + +echo "==> Running tests against external PDP (audit log → ${AUDIT_LOG})" + +GRADLE_ARGS=(test --rerun-tasks --no-daemon) +if [ "$#" -gt 0 ]; then + GRADLE_ARGS+=("$@") +fi + +if command -v gradle >/dev/null 2>&1; then + gradle "${GRADLE_ARGS[@]}" + TEST_EXIT=$? +else + echo "==> No local gradle found; falling back to gradle:8.12-jdk17 Docker image" + docker run --rm \ + -v "$(pwd)/..":/app \ + --network host \ + -e CERBOS_HOST="${CERBOS_HOST}" \ + -e CERBOS_PORT="${CERBOS_PORT}" \ + -w /app/spring-data \ + gradle:8.12-jdk17 \ + gradle "${GRADLE_ARGS[@]}" + TEST_EXIT=$? +fi + +# Stop following the audit log and summarise what the PDP actually served. +kill "${AUDIT_PID}" 2>/dev/null || true +wait "${AUDIT_PID}" 2>/dev/null || true + +PLAN_COUNT=$(grep -c '"PlanResources"' "${AUDIT_LOG}" 2>/dev/null || true) +CHECK_COUNT=$(grep -c '"CheckResources"' "${AUDIT_LOG}" 2>/dev/null || true) + +echo +echo "==> Cerbos PDP audit summary" +echo " PlanResources calls served: ${PLAN_COUNT:-0}" +echo " CheckResources calls served: ${CHECK_COUNT:-0}" +echo " Audit log archived at: ${AUDIT_LOG}" +echo +echo "==> Sample decision log entries:" +grep -E '"kind"|"action"|"method"|PlanResources' "${AUDIT_LOG}" 2>/dev/null | head -5 || true + +# Don't auto-rm the audit log on success — leave it for inspection. +trap 'cleanup; kill "${AUDIT_PID}" 2>/dev/null || true' EXIT INT TERM + +exit "${TEST_EXIT}" diff --git a/spring-data/settings.gradle.kts b/spring-data/settings.gradle.kts new file mode 100644 index 00000000..16610b69 --- /dev/null +++ b/spring-data/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "cerbos-spring-data" diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/AttributeMapping.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/AttributeMapping.java new file mode 100644 index 00000000..662a7aeb --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/AttributeMapping.java @@ -0,0 +1,31 @@ +package dev.cerbos.queryplan.springdata; + +import java.util.Map; + +public sealed interface AttributeMapping permits AttributeMapping.Field, AttributeMapping.Relation { + + static Field field(String jpaPath) { + return new Field(jpaPath); + } + + static Relation relation(String joinAttribute) { + return new Relation(joinAttribute, null, Map.of()); + } + + static Relation relation(String joinAttribute, String defaultMemberField) { + return new Relation(joinAttribute, defaultMemberField, Map.of()); + } + + static Relation relation(String joinAttribute, Map fields) { + return new Relation(joinAttribute, null, fields); + } + + static Relation relation(String joinAttribute, String defaultMemberField, Map fields) { + return new Relation(joinAttribute, defaultMemberField, fields); + } + + record Field(String jpaPath) implements AttributeMapping {} + + record Relation(String joinAttribute, String defaultMemberField, Map fields) + implements AttributeMapping {} +} diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java new file mode 100644 index 00000000..f6eb5fc2 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java @@ -0,0 +1,14 @@ +package dev.cerbos.queryplan.springdata; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; + +/** + * Override hook for translating a Cerbos operator + (field, value) pair into a JPA {@link Predicate}. + * The {@code field} expression is already resolved to a typed JPA path (or join) under the current scope. + */ +@FunctionalInterface +public interface OperatorFunction { + Predicate apply(CriteriaBuilder cb, Expression field, Object value); +} diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java new file mode 100644 index 00000000..fc6d3739 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java @@ -0,0 +1,40 @@ +package dev.cerbos.queryplan.springdata; + +import org.springframework.data.jpa.domain.Specification; + +public sealed interface Result permits Result.AlwaysAllowed, Result.AlwaysDenied, Result.Conditional { + + /** + * Returns a {@link Specification} that captures this result, so it composes cleanly with the + * caller's own Specifications via {@code .and(...)} / {@code .or(...)}: + * + *
    + *
  • {@link AlwaysAllowed} – always-true predicate ({@code 1=1})
  • + *
  • {@link AlwaysDenied} – always-false predicate ({@code 1=0})
  • + *
  • {@link Conditional} – the wrapped Specification
  • + *
+ */ + Specification toSpecification(); + + record AlwaysAllowed() implements Result { + @Override + public Specification toSpecification() { + return (root, query, cb) -> cb.conjunction(); + } + } + + record AlwaysDenied() implements Result { + @Override + public Specification toSpecification() { + return (root, query, cb) -> cb.disjunction(); + } + } + + record Conditional(Specification specification) implements Result { + @Override + public Specification toSpecification() { + return specification; + } + } +} + diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java new file mode 100644 index 00000000..9b95e244 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -0,0 +1,896 @@ +package dev.cerbos.queryplan.springdata; + +import com.google.protobuf.Value; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression.Operand; +import dev.cerbos.api.v1.response.Response.PlanResourcesResponse; +import dev.cerbos.sdk.PlanResourcesResult; + +import jakarta.persistence.criteria.AbstractQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Translates a Cerbos {@code PlanResources} response into a Spring Data JPA + * {@link org.springframework.data.jpa.domain.Specification} that can be executed by any + * {@code JpaSpecificationExecutor}. + */ +public final class SpringDataQueryPlanAdapter { + + // Alias for the deeply-nested protobuf type to avoid collision with jakarta.persistence.criteria.Expression + private static final class PlanExpr { + private PlanExpr() {} + } + + private SpringDataQueryPlanAdapter() {} + + // -- PlanResourcesResult overloads -- + + public static Result toSpecification( + PlanResourcesResult planResult, Map mapper) { + return toSpecification(planResult, mapper, Map.of()); + } + + public static Result toSpecification( + PlanResourcesResult planResult, + Map mapper, + Map overrides) { + if (planResult.isAlwaysAllowed()) { + return new Result.AlwaysAllowed<>(); + } + if (planResult.isAlwaysDenied()) { + return new Result.AlwaysDenied<>(); + } + Operand condition = planResult.getCondition() + .orElseThrow(() -> new IllegalArgumentException("Conditional plan has no condition")); + return new Result.Conditional<>((root, query, cb) -> + new Translator(cb, mapper, overrides).traverse(condition, Scope.root(root, query, mapper))); + } + + // -- PlanResourcesResponse overloads -- + + public static Result toSpecification( + PlanResourcesResponse response, Map mapper) { + return toSpecification(response, mapper, Map.of()); + } + + public static Result toSpecification( + PlanResourcesResponse response, + Map mapper, + Map overrides) { + PlanResourcesFilter filter = response.getFilter(); + return switch (filter.getKind()) { + case KIND_ALWAYS_ALLOWED -> new Result.AlwaysAllowed<>(); + case KIND_ALWAYS_DENIED -> new Result.AlwaysDenied<>(); + case KIND_CONDITIONAL -> { + Operand cond = filter.getCondition(); + if (cond.getNodeCase() == Operand.NodeCase.NODE_NOT_SET) { + throw new IllegalArgumentException("Conditional plan has no condition"); + } + yield new Result.Conditional((root, query, cb) -> + new Translator(cb, mapper, overrides).traverse(cond, Scope.root(root, query, mapper))); + } + default -> throw new IllegalArgumentException("Unknown filter kind: " + filter.getKind()); + }; + } + + // -- Internal translator -- + + private static final class Translator { + private final CriteriaBuilder cb; + private final Map topMapper; + private final Map overrides; + + Translator(CriteriaBuilder cb, + Map topMapper, + Map overrides) { + this.cb = cb; + this.topMapper = topMapper; + this.overrides = overrides; + } + + Predicate traverse(Operand operand, Scope scope) { + return switch (operand.getNodeCase()) { + case EXPRESSION -> traverseExpression(operand.getExpression(), scope); + case VARIABLE -> handleBareVariable(operand.getVariable(), scope); + default -> throw new IllegalArgumentException("Unexpected operand type: " + operand.getNodeCase()); + }; + } + + private Predicate handleBareVariable(String variable, Scope scope) { + Path path = scope.resolvePath(variable); + OperatorFunction fn = overrides.get("eq"); + if (fn != null) { + return fn.apply(cb, path, true); + } + return cb.equal(path, true); + } + + private Predicate traverseExpression(PlanResourcesFilter.Expression expression, Scope scope) { + String op = expression.getOperator(); + List operands = expression.getOperandsList(); + + return switch (op) { + case "and" -> cb.and(operands.stream() + .map(o -> traverse(o, scope)).toArray(Predicate[]::new)); + case "or" -> cb.or(operands.stream() + .map(o -> traverse(o, scope)).toArray(Predicate[]::new)); + case "not" -> { + if (operands.size() != 1) { + throw new IllegalArgumentException("not requires exactly 1 operand"); + } + yield cb.not(traverse(operands.get(0), scope)); + } + case "exists", "exists_one", "all", "except", "filter" -> + handleCollectionOperator(op, operands, scope); + case "hasIntersection" -> handleHasIntersection(operands, scope); + case "isSet" -> handleIsSet(operands, scope); + case "in" -> handleIn(operands, scope); + default -> { + Predicate sizePred = trySizeComparison(op, operands, scope); + if (sizePred != null) { + yield sizePred; + } + yield handleLeafOperator(op, operands, scope); + } + }; + } + + // -- Leaf operators (eq/ne/lt/gt/le/ge/contains/startsWith/endsWith) -- + + private Predicate handleLeafOperator(String op, List operands, Scope scope) { + // Detect leaf comparisons where one side is an 'add' expression (e.g. string + // concatenation: `aString == "prefix:" + R.attr.id`). We fold constants and solve for + // the field side when possible — same algorithm as the Prisma adapter. + Operand addExprOperand = null; + Operand otherOperand = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "add".equals(o.getExpression().getOperator())) { + addExprOperand = o; + } else { + otherOperand = o; + } + } + if (addExprOperand != null) { + if (otherOperand == null) { + throw new IllegalArgumentException("add comparison requires a second operand"); + } + return handleAddComparison(op, addExprOperand.getExpression(), otherOperand, scope); + } + + String variable = null; + Object value = null; + boolean valueSeen = false; + for (Operand o : operands) { + switch (o.getNodeCase()) { + case VARIABLE -> variable = o.getVariable(); + case VALUE -> { + value = protoValueToJava(o.getValue()); + valueSeen = true; + } + default -> throw new IllegalArgumentException( + "Unexpected operand type in leaf expression: " + o.getNodeCase()); + } + } + if (variable == null) { + throw new IllegalArgumentException("Missing variable operand for " + op); + } + if (!valueSeen) { + throw new IllegalArgumentException("Missing value operand for " + op); + } + + Path path = scope.resolvePath(variable); + + if (value == null) { + return switch (op) { + case "eq" -> cb.isNull(path); + case "ne" -> cb.isNotNull(path); + default -> throw new IllegalArgumentException( + "Null values are only supported with eq and ne operators (got " + op + ")"); + }; + } + + OperatorFunction override = overrides.get(op); + if (override != null) { + return override.apply(cb, path, value); + } + + return defaultLeaf(op, path, value); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private Predicate defaultLeaf(String op, Path path, Object value) { + Path raw = path; + return switch (op) { + case "eq" -> cb.equal(path, value); + case "ne" -> cb.notEqual(path, value); + case "lt" -> cb.lessThan(raw, (Comparable) value); + case "gt" -> cb.greaterThan(raw, (Comparable) value); + case "le" -> cb.lessThanOrEqualTo(raw, (Comparable) value); + case "ge" -> cb.greaterThanOrEqualTo(raw, (Comparable) value); + case "contains" -> cb.like(path.as(String.class), "%" + escapeLike(String.valueOf(value)) + "%", '\\'); + case "startsWith" -> cb.like(path.as(String.class), escapeLike(String.valueOf(value)) + "%", '\\'); + case "endsWith" -> cb.like(path.as(String.class), "%" + escapeLike(String.valueOf(value)), '\\'); + default -> throw new IllegalArgumentException("Unknown operator: " + op); + }; + } + + private static String escapeLike(String s) { + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } + + // -- add (fold + solve for string concat / numeric translation) -- + + private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression addExpr, + Operand otherOperand, Scope scope) { + List addOperands = addExpr.getOperandsList(); + if (addOperands.size() != 2) { + throw new IllegalArgumentException("add requires exactly 2 operands"); + } + Operand addLeft = addOperands.get(0); + Operand addRight = addOperands.get(1); + + // Case 1: add(value, value) — fold the two constants, then compare to the field. + if (addLeft.getNodeCase() == Operand.NodeCase.VALUE + && addRight.getNodeCase() == Operand.NodeCase.VALUE) { + Object folded = foldAdd( + protoValueToJava(addLeft.getValue()), + protoValueToJava(addRight.getValue())); + if (otherOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException( + "add(const, const) compared to a non-field operand is not supported"); + } + Path path = scope.resolvePath(otherOperand.getVariable()); + return defaultLeaf(op, path, folded); + } + + // Case 2: add(field, value) or add(value, field) — solve for the field. + // Only eq/ne are supported; lt/gt/etc. against a synthesized expression would require + // emitting more complex predicates we don't try to support here. + if (!"eq".equals(op) && !"ne".equals(op)) { + throw new IllegalArgumentException( + "add comparison with a field reference only supports eq/ne (got " + op + ")"); + } + if (otherOperand.getNodeCase() != Operand.NodeCase.VALUE) { + throw new IllegalArgumentException( + "add(field, value) requires a value on the other side of the comparison"); + } + Object otherValue = protoValueToJava(otherOperand.getValue()); + + Operand fieldOp; + Object addConst; + boolean fieldIsLeft; + if (addLeft.getNodeCase() == Operand.NodeCase.VARIABLE + && addRight.getNodeCase() == Operand.NodeCase.VALUE) { + fieldOp = addLeft; + addConst = protoValueToJava(addRight.getValue()); + fieldIsLeft = true; + } else if (addLeft.getNodeCase() == Operand.NodeCase.VALUE + && addRight.getNodeCase() == Operand.NodeCase.VARIABLE) { + fieldOp = addRight; + addConst = protoValueToJava(addLeft.getValue()); + fieldIsLeft = false; + } else { + throw new IllegalArgumentException( + "add requires exactly one field reference and one value, or two values"); + } + + Object solved = solveAdd(otherValue, addConst, fieldIsLeft); + if (solved == null) { + // No solution exists (e.g. "projects:123" == "users:" + R.id can never be true). + // eq → always-false; ne → always-true. + return "eq".equals(op) ? cb.disjunction() : cb.conjunction(); + } + Path path = scope.resolvePath(fieldOp.getVariable()); + return defaultLeaf(op, path, solved); + } + + // -- isSet -- + + private Predicate handleIsSet(List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException("isSet requires exactly 2 operands"); + } + String variable = null; + Boolean flag = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.VARIABLE) variable = o.getVariable(); + else if (o.getNodeCase() == Operand.NodeCase.VALUE) { + Object v = protoValueToJava(o.getValue()); + if (!(v instanceof Boolean b)) { + throw new IllegalArgumentException("isSet second operand must be a boolean"); + } + flag = b; + } + } + if (variable == null || flag == null) { + throw new IllegalArgumentException("Invalid isSet operands"); + } + Path path = scope.resolvePath(variable); + return flag ? cb.isNotNull(path) : cb.isNull(path); + } + + // -- in (set membership or collection membership) -- + + private Predicate handleIn(List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException("in requires exactly 2 operands"); + } + Operand left = operands.get(0); + Operand right = operands.get(1); + + if (left.getNodeCase() == Operand.NodeCase.VARIABLE + && right.getNodeCase() == Operand.NodeCase.VALUE) { + String var = left.getVariable(); + Object val = protoValueToJava(right.getValue()); + + AttributeMapping mapping = scope.resolveMapping(var); + if (mapping instanceof AttributeMapping.Relation rel) { + List values = (val instanceof List l) ? l : List.of(val); + return collectionContainsAny(scope, rel, values); + } + + Path path = scope.resolvePath(var); + if (val instanceof List list) { + if (list.isEmpty()) { + return cb.disjunction(); + } + return path.in(list); + } + return cb.equal(path, val); + } + + if (left.getNodeCase() == Operand.NodeCase.VALUE + && right.getNodeCase() == Operand.NodeCase.VARIABLE) { + Object val = protoValueToJava(left.getValue()); + String var = right.getVariable(); + + AttributeMapping mapping = scope.resolveMapping(var); + if (mapping instanceof AttributeMapping.Relation rel) { + return collectionContainsAny(scope, rel, List.of(val)); + } + Path path = scope.resolvePath(var); + return cb.equal(path, val); + } + + throw new IllegalArgumentException( + "Unsupported in operand combination: " + left.getNodeCase() + "/" + right.getNodeCase()); + } + + // -- hasIntersection -- + + private Predicate handleHasIntersection(List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException("hasIntersection requires exactly 2 operands"); + } + Operand first = operands.get(0); + Operand second = operands.get(1); + + if (first.getNodeCase() == Operand.NodeCase.VARIABLE + && second.getNodeCase() == Operand.NodeCase.VALUE) { + String var = first.getVariable(); + Object val = protoValueToJava(second.getValue()); + List values = (val instanceof List l) ? l : List.of(val); + + AttributeMapping mapping = scope.resolveMapping(var); + if (mapping instanceof AttributeMapping.Relation rel) { + return collectionContainsAny(scope, rel, values); + } + Path path = scope.resolvePath(var); + return path.in(values); + } + + if (first.getNodeCase() == Operand.NodeCase.EXPRESSION + && "map".equals(first.getExpression().getOperator())) { + if (second.getNodeCase() != Operand.NodeCase.VALUE) { + throw new IllegalArgumentException( + "hasIntersection second operand must be a value list when used with map()"); + } + Object val = protoValueToJava(second.getValue()); + List values = (val instanceof List l) ? l : List.of(val); + + PlanResourcesFilter.Expression mapExpr = first.getExpression(); + List mapOperands = mapExpr.getOperandsList(); + if (mapOperands.size() != 2) { + throw new IllegalArgumentException("map requires exactly 2 operands"); + } + Operand collectionOperand = mapOperands.get(0); + Operand lambdaOperand = mapOperands.get(1); + + if (collectionOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("map first operand must be a variable"); + } + if (lambdaOperand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"lambda".equals(lambdaOperand.getExpression().getOperator())) { + throw new IllegalArgumentException("map second operand must be a lambda"); + } + + String collectionVar = collectionOperand.getVariable(); + + PlanResourcesFilter.Expression lambdaExpr = lambdaOperand.getExpression(); + List lambdaOps = lambdaExpr.getOperandsList(); + Operand projection = lambdaOps.get(0); + Operand lambdaVar = lambdaOps.get(1); + if (projection.getNodeCase() != Operand.NodeCase.VARIABLE + || lambdaVar.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("map lambda body must be a simple variable projection"); + } + String memberField = extractLambdaSuffix(projection.getVariable(), lambdaVar.getVariable()); + + // Check whether the collection path resolves through one Relation or a chain. + // A chain (e.g. "request.resource.attr.categories.subCategories") emits nested + // EXISTS subqueries — one per hop. + if (scope instanceof Scope.RootScope rootScope) { + RelationChain chain = resolveRelationChain(rootScope.mapper(), collectionVar); + if (chain != null && !chain.relations().isEmpty()) { + AttributeMapping.Relation tailRel = chain.relations().get(chain.relations().size() - 1); + return chainedExistsSubquery(scope, chain.relations(), (sub, joinFrom) -> { + Path field = resolveMemberPath(joinFrom, tailRel, memberField); + return field.in(values); + }); + } + } + + AttributeMapping mapping = scope.resolveMapping(collectionVar); + if (mapping instanceof AttributeMapping.Relation rel) { + return existsSubquery(scope, rel, (sub, joinFrom) -> { + Path field = resolveMemberPath(joinFrom, rel, memberField); + return field.in(values); + }); + } + throw new IllegalArgumentException( + "map can only be applied to a collection mapped as Relation: " + collectionVar); + } + + throw new IllegalArgumentException( + "Unsupported hasIntersection operand shape: " + first.getNodeCase()); + } + + private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relation rel, List values) { + return existsSubquery(outerScope, rel, (sub, joinFrom) -> { + Path field; + if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { + field = joinFrom.get(rel.defaultMemberField()); + } else { + // @ElementCollection - the join itself is the element value + field = (Path) joinFrom; + } + if (values.size() == 1) { + return cb.equal(field, values.get(0)); + } + return field.in(values); + }); + } + + // -- size(collection) N -- + + private Predicate trySizeComparison(String op, List operands, Scope scope) { + PlanResourcesFilter.Expression sizeExpr = null; + Long numValue = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "size".equals(o.getExpression().getOperator())) { + sizeExpr = o.getExpression(); + } else if (o.getNodeCase() == Operand.NodeCase.VALUE) { + Object v = protoValueToJava(o.getValue()); + if (v instanceof Number n) numValue = n.longValue(); + } + } + if (sizeExpr == null || numValue == null) { + return null; + } + List sizeOps = sizeExpr.getOperandsList(); + if (sizeOps.size() != 1 || sizeOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("Unsupported size() expression"); + } + String var = sizeOps.get(0).getVariable(); + AttributeMapping mapping = scope.resolveMapping(var); + if (!(mapping instanceof AttributeMapping.Relation rel)) { + throw new IllegalArgumentException("size() requires a collection (Relation) mapping for " + var); + } + + boolean nonEmpty = ("gt".equals(op) && numValue == 0L) || ("ge".equals(op) && numValue == 1L); + boolean empty = ("eq".equals(op) && numValue == 0L) + || ("le".equals(op) && numValue == 0L) + || ("lt".equals(op) && numValue == 1L); + + if (nonEmpty) { + return existsSubquery(scope, rel, (sub, joinFrom) -> cb.conjunction()); + } + if (empty) { + return cb.not(existsSubquery(scope, rel, (sub, joinFrom) -> cb.conjunction())); + } + throw new IllegalArgumentException( + "Unsupported size comparison: size(" + var + ") " + op + " " + numValue + + ". Only emptiness checks (size > 0, size == 0) are supported."); + } + + // -- exists / exists_one / all / except / filter -- + + @SuppressWarnings("unchecked") + private Predicate handleCollectionOperator(String op, List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException(op + " requires exactly 2 operands"); + } + Operand listOperand = operands.get(0); + Operand lambdaOperand = operands.get(1); + + if (listOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException(op + " first operand must be a variable"); + } + if (lambdaOperand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"lambda".equals(lambdaOperand.getExpression().getOperator())) { + throw new IllegalArgumentException(op + " second operand must be a lambda"); + } + + String collectionVar = listOperand.getVariable(); + AttributeMapping mapping = scope.resolveMapping(collectionVar); + if (!(mapping instanceof AttributeMapping.Relation rel)) { + throw new IllegalArgumentException( + op + " requires a Relation mapping for " + collectionVar); + } + + PlanResourcesFilter.Expression lambdaExpr = lambdaOperand.getExpression(); + List lambdaOps = lambdaExpr.getOperandsList(); + if (lambdaOps.size() != 2) { + throw new IllegalArgumentException("lambda requires exactly 2 operands"); + } + Operand body = lambdaOps.get(0); + Operand lambdaVar = lambdaOps.get(1); + if (lambdaVar.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("lambda variable must be a variable operand"); + } + String lambdaVarName = lambdaVar.getVariable(); + + return switch (op) { + case "exists", "filter" -> existsSubquery(scope, rel, + (sub, joinFrom) -> traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))); + case "except" -> existsSubquery(scope, rel, + (sub, joinFrom) -> cb.not(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName)))); + case "all" -> cb.not(existsSubquery(scope, rel, + (sub, joinFrom) -> cb.not(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))))); + case "exists_one" -> { + Subquery sub = scope.parentQuery().subquery(Long.class); + From outerFrom = scope.from(); + From correlated; + if (outerFrom instanceof Root r) { + correlated = sub.correlate(r); + } else if (outerFrom instanceof Join j) { + correlated = sub.correlate((Join) j); + } else { + throw new IllegalArgumentException("Cannot correlate scope: " + outerFrom); + } + Join joinFrom = correlated.join(rel.joinAttribute()); + sub.select(cb.count(joinFrom)); + sub.where(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))); + yield cb.equal(sub, 1L); + } + default -> throw new IllegalArgumentException("Unsupported collection operator: " + op); + }; + } + + @FunctionalInterface + private interface SubqueryBodyBuilder { + Predicate build(Subquery sub, From joinFrom); + } + + /** + * Build nested EXISTS subqueries for a chain of Relations: the outermost EXISTS joins the + * first Relation, an inner EXISTS correlates from that join through the next, and so on. + * The {@code bodyBuilder} produces the leaf predicate against the innermost join. + */ + private Predicate chainedExistsSubquery(Scope scope, + java.util.List chain, + SubqueryBodyBuilder bodyBuilder) { + if (chain.size() == 1) { + return existsSubquery(scope, chain.get(0), bodyBuilder); + } + return existsSubquery(scope, chain.get(0), (sub, joinFrom) -> { + // Recurse using an intermediate scope rooted at the current join + this subquery. + AttributeMapping.Relation thisRel = chain.get(0); + Scope intermediate = Scope.lambda(joinFrom, sub, thisRel, "__chain__"); + return chainedExistsSubquery(intermediate, chain.subList(1, chain.size()), bodyBuilder); + }); + } + + @SuppressWarnings("unchecked") + private Predicate existsSubquery(Scope scope, AttributeMapping.Relation rel, SubqueryBodyBuilder bodyBuilder) { + From outerFrom = scope.from(); + Subquery sub = scope.parentQuery().subquery(Integer.class); + From correlated; + if (outerFrom instanceof Root r) { + correlated = sub.correlate(r); + } else if (outerFrom instanceof Join j) { + correlated = sub.correlate((Join) j); + } else { + throw new IllegalArgumentException("Cannot correlate from non-Root, non-Join scope: " + outerFrom); + } + Join joinFrom = correlated.join(rel.joinAttribute()); + sub.select(cb.literal(1)); + Predicate body = bodyBuilder.build(sub, joinFrom); + sub.where(body); + return cb.exists(sub); + } + } + + // -- Scope -- + + private sealed interface Scope permits Scope.RootScope, Scope.LambdaScope { + Path resolvePath(String cerbosVar); + + AttributeMapping resolveMapping(String cerbosVar); + + From from(); + + AbstractQuery parentQuery(); + + static Scope root(From root, AbstractQuery query, Map mapper) { + return new RootScope(root, query, mapper); + } + + static Scope lambda(From from, AbstractQuery parentQuery, + AttributeMapping.Relation relation, String lambdaVar) { + return new LambdaScope(from, parentQuery, relation, lambdaVar); + } + + record RootScope(From from, AbstractQuery parentQuery, Map mapper) + implements Scope { + @Override + public Path resolvePath(String cerbosVar) { + AttributeMapping m = mapper.get(cerbosVar); + if (m == null) { + throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); + } + if (m instanceof AttributeMapping.Field f) { + return traversePath(from, f.jpaPath()); + } + throw new IllegalArgumentException( + "Attribute " + cerbosVar + " is a Relation; cannot resolve as a scalar path"); + } + + @Override + public AttributeMapping resolveMapping(String cerbosVar) { + AttributeMapping m = mapper.get(cerbosVar); + if (m != null) { + return m; + } + + // Try resolving as a dotted suffix off a registered Relation prefix. + // Example: mapper has "request.resource.attr.categories" → Relation("categories", fields={"subCategories": Relation(...)}) + // and we're asked for "request.resource.attr.categories.subCategories" — walk the chain. + String[] parts = cerbosVar.split("\\."); + for (int i = parts.length - 1; i > 0; i--) { + String prefix = String.join(".", java.util.Arrays.copyOfRange(parts, 0, i)); + AttributeMapping prefixMapping = mapper.get(prefix); + if (prefixMapping instanceof AttributeMapping.Relation rel) { + AttributeMapping resolved = walkRelationChain(rel, + java.util.Arrays.copyOfRange(parts, i, parts.length)); + if (resolved != null) { + return resolved; + } + } + } + + throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); + } + } + + record LambdaScope(From from, AbstractQuery parentQuery, + AttributeMapping.Relation relation, String lambdaVar) implements Scope { + @Override + public Path resolvePath(String cerbosVar) { + String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); + if (suffix.isEmpty()) { + if (relation.defaultMemberField() != null && !relation.defaultMemberField().isEmpty()) { + return from.get(relation.defaultMemberField()); + } + return (Path) from; + } + AttributeMapping nested = relation.fields().get(suffix); + if (nested instanceof AttributeMapping.Field f) { + return traversePath(from, f.jpaPath()); + } + return traversePath(from, suffix); + } + + @Override + public AttributeMapping resolveMapping(String cerbosVar) { + String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); + if (suffix.isEmpty()) { + return relation; + } + AttributeMapping nested = relation.fields().get(suffix); + if (nested != null) { + return nested; + } + return AttributeMapping.field(suffix); + } + } + } + + // -- helpers -- + + /** + * Fold {@code add(left, right)} where both operands are constants. Strings concatenate; + * numbers add. Used when the planner emits e.g. {@code eq(field, add("prefix:", "123"))}. + */ + static Object foldAdd(Object left, Object right) { + if (left instanceof String || right instanceof String) { + return String.valueOf(left) + String.valueOf(right); + } + if (left instanceof Number ln && right instanceof Number rn) { + if (left instanceof Long && right instanceof Long) { + return ln.longValue() + rn.longValue(); + } + return ln.doubleValue() + rn.doubleValue(); + } + throw new IllegalArgumentException( + "add requires string or numeric operands, got " + left.getClass() + " + " + right.getClass()); + } + + /** + * Solve {@code field + addConstant == comparisonValue} (or with operands swapped if + * {@code !fieldIsLeft}). For strings: strip the prefix/suffix and return what the field must + * equal; return {@code null} if the comparison value doesn't match the constant's + * shape (which means no field value can satisfy the equation). For numbers: subtract. + */ + static Object solveAdd(Object comparisonValue, Object addConstant, boolean fieldIsLeft) { + if (comparisonValue instanceof String compStr && addConstant instanceof String constStr) { + if (fieldIsLeft) { + // field + const == comparison → field == comparison stripped-of-suffix + if (!compStr.endsWith(constStr)) return null; + return compStr.substring(0, compStr.length() - constStr.length()); + } + // const + field == comparison → field == comparison stripped-of-prefix + if (!compStr.startsWith(constStr)) return null; + return compStr.substring(constStr.length()); + } + if (comparisonValue instanceof Number compNum && addConstant instanceof Number constNum) { + // Both orderings of numeric addition produce the same equation: field = comp - const + if (comparisonValue instanceof Long && addConstant instanceof Long) { + return compNum.longValue() - constNum.longValue(); + } + return compNum.doubleValue() - constNum.doubleValue(); + } + throw new IllegalArgumentException( + "add comparison type mismatch: " + comparisonValue.getClass() + " vs " + addConstant.getClass()); + } + + /** + * Walk a dotted suffix through a Relation's nested {@code fields()} map. Returns the leaf + * mapping (Field or Relation) reached, or {@code null} if any segment doesn't resolve. + */ + private static AttributeMapping walkRelationChain(AttributeMapping.Relation rel, String[] suffixParts) { + AttributeMapping current = rel; + for (String part : suffixParts) { + if (!(current instanceof AttributeMapping.Relation r)) { + return null; + } + AttributeMapping next = r.fields().get(part); + if (next == null) { + return null; + } + current = next; + } + return current; + } + + /** + * Resolve a dotted top-level Cerbos attribute to a chain of Relations, ending in either a + * leaf Field or the final Relation. Used by {@code hasIntersection(map(...))} when the map's + * collection operand is a dotted path through nested Relation mappings. + */ + record RelationChain(List relations, AttributeMapping.Field tail) {} + + private static RelationChain resolveRelationChain(Map mapper, String cerbosVar) { + AttributeMapping direct = mapper.get(cerbosVar); + if (direct instanceof AttributeMapping.Relation rel) { + return new RelationChain(List.of(rel), null); + } + String[] parts = cerbosVar.split("\\."); + for (int i = parts.length - 1; i > 0; i--) { + String prefix = String.join(".", java.util.Arrays.copyOfRange(parts, 0, i)); + AttributeMapping prefixMapping = mapper.get(prefix); + if (!(prefixMapping instanceof AttributeMapping.Relation rel)) { + continue; + } + String[] suffixParts = java.util.Arrays.copyOfRange(parts, i, parts.length); + java.util.List chain = new java.util.ArrayList<>(); + chain.add(rel); + AttributeMapping current = rel; + boolean ok = true; + for (int s = 0; s < suffixParts.length; s++) { + if (!(current instanceof AttributeMapping.Relation r)) { + ok = false; + break; + } + AttributeMapping next = r.fields().get(suffixParts[s]); + if (next == null) { + ok = false; + break; + } + if (next instanceof AttributeMapping.Relation nextRel) { + chain.add(nextRel); + current = nextRel; + } else if (next instanceof AttributeMapping.Field leafField && s == suffixParts.length - 1) { + return new RelationChain(chain, leafField); + } else { + ok = false; + break; + } + } + if (ok) { + return new RelationChain(chain, null); + } + } + return null; + } + + private static Path resolveMemberPath(From joinFrom, AttributeMapping.Relation rel, String memberField) { + if (memberField == null || memberField.isEmpty()) { + if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { + return joinFrom.get(rel.defaultMemberField()); + } + return (Path) joinFrom; + } + AttributeMapping nested = rel.fields().get(memberField); + if (nested instanceof AttributeMapping.Field f) { + return traversePath(joinFrom, f.jpaPath()); + } + return traversePath(joinFrom, memberField); + } + + private static Path traversePath(From from, String dottedJpaPath) { + String[] parts = dottedJpaPath.split("\\."); + Path p = from; + for (String part : parts) { + p = p.get(part); + } + return p; + } + + private static String extractLambdaSuffix(String variable, String lambdaVar) { + if (variable.equals(lambdaVar)) { + return ""; + } + String prefix = lambdaVar + "."; + if (!variable.startsWith(prefix)) { + throw new IllegalArgumentException( + "Variable '" + variable + "' does not start with lambda variable '" + lambdaVar + "'"); + } + return variable.substring(prefix.length()); + } + + static Object protoValueToJava(Value value) { + return switch (value.getKindCase()) { + case STRING_VALUE -> value.getStringValue(); + case NUMBER_VALUE -> { + double d = value.getNumberValue(); + if (d == Math.floor(d) && !Double.isInfinite(d)) { + yield (long) d; + } + yield d; + } + case BOOL_VALUE -> value.getBoolValue(); + case NULL_VALUE -> null; + case LIST_VALUE -> value.getListValue().getValuesList().stream() + .map(SpringDataQueryPlanAdapter::protoValueToJava) + .toList(); + case STRUCT_VALUE -> value.getStructValue().getFieldsMap().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> protoValueToJava(e.getValue()))); + default -> throw new IllegalArgumentException( + "Unsupported protobuf value type: " + value.getKindCase()); + }; + } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java new file mode 100644 index 00000000..04b42a64 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -0,0 +1,886 @@ +package dev.cerbos.queryplan.springdata; + +import dev.cerbos.queryplan.springdata.testmodel.CategoryEntity; +import dev.cerbos.queryplan.springdata.testmodel.LabelEntity; +import dev.cerbos.queryplan.springdata.testmodel.NestedEmbeddable; +import dev.cerbos.queryplan.springdata.testmodel.NextLevelEmbeddable; +import dev.cerbos.queryplan.springdata.testmodel.OwnerEntity; +import dev.cerbos.queryplan.springdata.testmodel.ResourceEntity; +import dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity; +import dev.cerbos.sdk.CerbosBlockingClient; +import dev.cerbos.sdk.CerbosClientBuilder; +import dev.cerbos.sdk.PlanResourcesResult; +import dev.cerbos.sdk.builders.AttributeValue; +import dev.cerbos.sdk.builders.Principal; +import dev.cerbos.sdk.builders.Resource; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Order; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.domain.Specification; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * End-to-end test against a real Cerbos PDP. + * + *

Two modes: + *

    + *
  • Self-managed (default): a {@code ghcr.io/cerbos/cerbos:dev} container is started + * by Testcontainers, with the shared {@code /policies/resource.yaml} mounted in.
  • + *
  • External (Prisma-style sidecar): if {@code CERBOS_HOST} and {@code CERBOS_PORT} + * are set in the environment, the suite skips Testcontainers and connects to an + * externally-managed PDP. See {@code docker-compose.yml} and {@code scripts/run-e2e.sh}.
  • + *
+ */ +class SpringDataIntegrationTest { + + private static final String EXTERNAL_HOST = System.getenv("CERBOS_HOST"); + private static final String EXTERNAL_PORT = System.getenv("CERBOS_PORT"); + private static final boolean USE_EXTERNAL_PDP = EXTERNAL_HOST != null && !EXTERNAL_HOST.isBlank(); + + private static GenericContainer cerbos; + private static CerbosBlockingClient cerbosClient; + private static EntityManagerFactory emf; + + private static final Map FIELD_MAP = Map.ofEntries( + Map.entry("request.resource.attr.aBool", AttributeMapping.field("aBool")), + Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), + Map.entry("request.resource.attr.aNumber", AttributeMapping.field("aNumber")), + Map.entry("request.resource.attr.id", AttributeMapping.field("oid")), + Map.entry("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")), + Map.entry("request.resource.attr.createdBy", AttributeMapping.field("createdBy")), + Map.entry("request.resource.attr.ownedBy", AttributeMapping.relation("ownedBy")), + Map.entry("request.resource.attr.tags", AttributeMapping.relation("tagNames")), + Map.entry("request.resource.attr.nested.aBool", AttributeMapping.field("nested.aBool")), + Map.entry("request.resource.attr.nested.aString", AttributeMapping.field("nested.aString")), + Map.entry("request.resource.attr.nested.aNumber", AttributeMapping.field("nested.aNumber")), + Map.entry("request.resource.attr.nested.aOptionalString", AttributeMapping.field("nested.aOptionalString")), + Map.entry("request.resource.attr.nested.nextlevel.aBool", AttributeMapping.field("nested.nextlevel.aBool")), + Map.entry("request.resource.attr.nested.nextlevel.aString", AttributeMapping.field("nested.nextlevel.aString")) + ); + + // Combined map used by tests that reference both nested.* and categories (e.g. combined-or) + // or the full kitchen-sink (tags + nested + tagObjects + ...). + private static final Map COMBINED_MAP; + static { + java.util.HashMap m = new java.util.HashMap<>(); + m.put("request.resource.attr.aBool", AttributeMapping.field("aBool")); + m.put("request.resource.attr.aString", AttributeMapping.field("aString")); + m.put("request.resource.attr.aNumber", AttributeMapping.field("aNumber")); + m.put("request.resource.attr.id", AttributeMapping.field("oid")); + m.put("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")); + m.put("request.resource.attr.createdBy", AttributeMapping.field("createdBy")); + m.put("request.resource.attr.ownedBy", AttributeMapping.relation("ownedBy")); + // tags as the @OneToMany TagEntity collection (for exists/all/filter etc.) + m.put("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "id", AttributeMapping.field("id"), + "name", AttributeMapping.field("name") + ))); + m.put("request.resource.attr.nested.aBool", AttributeMapping.field("nested.aBool")); + m.put("request.resource.attr.nested.aString", AttributeMapping.field("nested.aString")); + m.put("request.resource.attr.nested.aNumber", AttributeMapping.field("nested.aNumber")); + m.put("request.resource.attr.nested.aOptionalString", AttributeMapping.field("nested.aOptionalString")); + m.put("request.resource.attr.nested.nextlevel.aBool", AttributeMapping.field("nested.nextlevel.aBool")); + m.put("request.resource.attr.nested.nextlevel.aString", AttributeMapping.field("nested.nextlevel.aString")); + m.put("request.resource.attr.categories", AttributeMapping.relation("categories", Map.of( + "name", AttributeMapping.field("name"), + "subCategories", AttributeMapping.relation("subCategories", Map.of( + "name", AttributeMapping.field("name"), + "labels", AttributeMapping.relation("labels", Map.of( + "name", AttributeMapping.field("name") + )) + )) + ))); + COMBINED_MAP = Map.copyOf(m); + } + + private static final Map CATEGORIES_MAP = Map.ofEntries( + Map.entry("request.resource.attr.categories", AttributeMapping.relation("categories", Map.of( + "name", AttributeMapping.field("name"), + "subCategories", AttributeMapping.relation("subCategories", Map.of( + "name", AttributeMapping.field("name"), + "labels", AttributeMapping.relation("labels", Map.of( + "name", AttributeMapping.field("name") + )) + )) + ))) + ); + + private static final Map NESTED_FIELD_MAP = Map.ofEntries( + Map.entry("request.resource.attr.aBool", AttributeMapping.field("aBool")), + Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), + Map.entry("request.resource.attr.aNumber", AttributeMapping.field("aNumber")), + Map.entry("request.resource.attr.id", AttributeMapping.field("oid")), + Map.entry("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")), + Map.entry("request.resource.attr.createdBy", AttributeMapping.field("createdBy")), + Map.entry("request.resource.attr.ownedBy", AttributeMapping.relation("ownedBy")), + Map.entry("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "id", AttributeMapping.field("id"), + "name", AttributeMapping.field("name") + ))), + Map.entry("request.resource.attr.nested.aBool", AttributeMapping.field("nested.aBool")), + Map.entry("request.resource.attr.nested.aString", AttributeMapping.field("nested.aString")), + Map.entry("request.resource.attr.nested.aNumber", AttributeMapping.field("nested.aNumber")) + ); + + private static GenericContainer createCerbosContainer() { + GenericContainer container = new GenericContainer<>("ghcr.io/cerbos/cerbos:latest") + .withExposedPorts(3593) + .withCommand("server", + "--set=storage.disk.directory=/policies", + "--set=schema.enforcement=reject", + "--set=audit.enabled=true", + "--set=audit.accessLogsEnabled=true", + "--set=audit.decisionLogsEnabled=true", + "--set=audit.backend=file", + "--set=audit.file.path=stdout") + .withEnv("CERBOS_NO_TELEMETRY", "1") + .waitingFor(Wait.forLogMessage(".*Starting gRPC server.*", 1)); + try { + byte[] policyBytes = Files.readAllBytes( + Path.of(System.getProperty("user.dir"), "..", "policies", "resource.yaml")); + container.withCopyToContainer(Transferable.of(policyBytes), "/policies/resource.yaml"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return container; + } + + @BeforeAll + static void setUp() throws Exception { + String host; + int port; + if (USE_EXTERNAL_PDP) { + host = EXTERNAL_HOST; + port = EXTERNAL_PORT != null && !EXTERNAL_PORT.isBlank() + ? Integer.parseInt(EXTERNAL_PORT) + : 3593; + System.out.printf("==> Using externally-managed Cerbos PDP at %s:%d%n", host, port); + } else { + cerbos = createCerbosContainer(); + // Stream the cerbos container's stdout (including audit/decision-log JSON lines) into + // the test JVM's logger so PlanResources calls are visibly logged alongside test runs. + cerbos.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("cerbos-pdp"))); + cerbos.start(); + host = cerbos.getHost(); + port = cerbos.getMappedPort(3593); + System.out.printf( + "==> Started Testcontainers-managed Cerbos PDP (ghcr.io/cerbos/cerbos:latest) at %s:%d%n", + host, port); + } + + cerbosClient = new CerbosClientBuilder(host + ":" + port) + .withPlaintext().buildBlockingClient(); + + emf = Persistence.createEntityManagerFactory("test-pu"); + seedData(); + } + + @AfterAll + static void tearDown() { + if (emf != null) emf.close(); + if (cerbos != null) { + cerbos.stop(); + } + } + + private static void seedData() { + EntityManager em = emf.createEntityManager(); + EntityTransaction tx = em.getTransaction(); + tx.begin(); + + // Labels + LabelEntity label1 = new LabelEntity("label1", "important"); + LabelEntity label2 = new LabelEntity("label2", "archived"); + LabelEntity label3 = new LabelEntity("label3", "flagged"); + em.persist(label1); + em.persist(label2); + em.persist(label3); + + // SubCategories + SubCategoryEntity sub1 = new SubCategoryEntity("sub1", "finance"); + sub1.setLabels(new java.util.ArrayList<>(List.of(label1, label2))); + SubCategoryEntity sub2 = new SubCategoryEntity("sub2", "tech"); + sub2.setLabels(new java.util.ArrayList<>(List.of(label2, label3))); + em.persist(sub1); + em.persist(sub2); + + // Categories + CategoryEntity cat1 = new CategoryEntity("cat1", "business"); + cat1.setSubCategories(new java.util.ArrayList<>(List.of(sub1))); + CategoryEntity cat2 = new CategoryEntity("cat2", "development"); + cat2.setSubCategories(new java.util.ArrayList<>(List.of(sub2))); + em.persist(cat1); + em.persist(cat2); + + // Owners + OwnerEntity user1 = new OwnerEntity("user1", "Alice", "engineering"); + OwnerEntity user2 = new OwnerEntity("user2", "Bob", "marketing"); + OwnerEntity user3 = new OwnerEntity("user3", "Carol", "sales"); + em.persist(user1); + em.persist(user2); + em.persist(user3); + + ResourceEntity r1 = new ResourceEntity("1"); + r1.setOid("507f1f77bcf86cd799439011"); + r1.setaBool(true); + r1.setaString("string"); + r1.setaNumber(1); + r1.setaOptionalString("hello"); + r1.setCreatedBy("user1"); + r1.setOwnedBy(new java.util.ArrayList<>(List.of("user1", "user2"))); + r1.setTagNames(new java.util.ArrayList<>(List.of("public", "featured"))); + r1.addTag("tag1", "public"); + r1.addTag("tag2", "private"); + r1.setCategories(new java.util.ArrayList<>(List.of(cat1))); + r1.setCreator(user1); + NestedEmbeddable n1 = new NestedEmbeddable(); + n1.setaBool(true); + n1.setaString("substring"); + n1.setaNumber(2); + NextLevelEmbeddable nl1 = new NextLevelEmbeddable(); + nl1.setaBool(true); + nl1.setaString("strDeep"); + n1.setNextlevel(nl1); + r1.setNested(n1); + em.persist(r1); + + ResourceEntity r2 = new ResourceEntity("2"); + r2.setOid("507f1f77bcf86cd799439012"); + r2.setaBool(false); + r2.setaString("amIAString?"); + r2.setaNumber(2); + r2.setCreatedBy("user2"); + r2.setOwnedBy(new java.util.ArrayList<>(List.of("user2"))); + r2.setTagNames(new java.util.ArrayList<>(List.of("private"))); + r2.addTag("tag3", "private"); + r2.setCategories(new java.util.ArrayList<>(List.of(cat2))); + r2.setCreator(user2); + NestedEmbeddable n2 = new NestedEmbeddable(); + n2.setaBool(false); + n2.setaString("noMatch"); + n2.setaNumber(1); + NextLevelEmbeddable nl2 = new NextLevelEmbeddable(); + nl2.setaBool(false); + nl2.setaString("deepValue"); + n2.setNextlevel(nl2); + r2.setNested(n2); + em.persist(r2); + + ResourceEntity r3 = new ResourceEntity("3"); + r3.setOid("507f1f77bcf86cd799439013"); + r3.setaBool(true); + r3.setaString("anotherString"); + r3.setaNumber(3); + r3.setaOptionalString("world"); + r3.setCreatedBy("user3"); + r3.setOwnedBy(new java.util.ArrayList<>(List.of("user1"))); + r3.setTagNames(new java.util.ArrayList<>(List.of("public"))); + r3.addTag("tag1", "public"); + r3.setCategories(new java.util.ArrayList<>(List.of(cat1, cat2))); + r3.setCreator(user3); + NestedEmbeddable n3 = new NestedEmbeddable(); + n3.setaBool(true); + n3.setaString("testString"); + n3.setaNumber(3); + NextLevelEmbeddable nl3 = new NextLevelEmbeddable(); + nl3.setaBool(false); + nl3.setaString("strValue"); + n3.setNextlevel(nl3); + r3.setNested(n3); + em.persist(r3); + + tx.commit(); + em.close(); + } + + private static PlanResourcesResult plan(String action) { + return plan(Principal.newInstance("user1", "USER"), action); + } + + private static PlanResourcesResult plan(Principal principal, String action) { + return cerbosClient.plan( + principal, + Resource.newInstance("resource"), + action); + } + + private static List runWithMapping(String action, Map mapping) { + return runWithPrincipalAndMapping(Principal.newInstance("user1", "USER"), action, mapping); + } + + private static List runWithPrincipalAndMapping( + Principal principal, String action, Map mapping) { + PlanResourcesResult planResult = plan(principal, action); + Result result = + SpringDataQueryPlanAdapter.toSpecification(planResult, mapping); + + if (result instanceof Result.AlwaysDenied) { + return List.of(); + } + + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root root = cq.from(ResourceEntity.class); + cq.select(root.get("id")).distinct(true); + + if (result instanceof Result.Conditional conditional) { + Specification spec = conditional.specification(); + Predicate p = spec.toPredicate(root, cq, cb); + if (p != null) { + cq.where(p); + } + } + cq.orderBy(cb.asc(root.get("id"))); + return em.createQuery(cq).getResultList(); + } finally { + em.close(); + } + } + + private static List run(String action) { + return runWithMapping(action, FIELD_MAP); + } + + private static List runNested(String action) { + return runWithMapping(action, NESTED_FIELD_MAP); + } + + // -- always allow/deny -- + + @Test + void alwaysAllowed() { + assertEquals(List.of("1", "2", "3"), run("always-allow")); + } + + @Test + void alwaysDenied() { + assertEquals(List.of(), run("always-deny")); + } + + // -- equality -- + + @Test + void equal() { + assertEquals(List.of("1", "3"), run("equal")); + } + + @Test + void equalOid() { + assertEquals(List.of("1"), run("equal-oid")); + } + + @Test + void notEquals() { + assertEquals(List.of("2", "3"), run("ne")); + } + + @Test + void explicitDeny() { + assertEquals(List.of("2"), run("explicit-deny")); + } + + // -- bare bool -- + + @Test + void bareBool() { + assertEquals(List.of("1", "3"), run("bare-bool")); + } + + @Test + void bareBoolNegated() { + assertEquals(List.of("2"), run("bare-bool-negated")); + } + + @Test + void bareBoolNested() { + assertEquals(List.of("1", "3"), run("bare-bool-nested")); + } + + @Test + void bareBoolNestedNegated() { + assertEquals(List.of("2"), run("bare-bool-nested-negated")); + } + + // -- logical -- + + @Test + void and() { + assertEquals(List.of("3"), run("and")); + } + + @Test + void or() { + assertEquals(List.of("1", "2", "3"), run("or")); + } + + @Test + void nand() { + assertEquals(List.of("1", "2"), run("nand")); + } + + @Test + void nor() { + assertEquals(List.of(), run("nor")); + } + + // -- set membership -- + + @Test + void in() { + assertEquals(List.of("1", "3"), run("in")); + } + + // -- range -- + + @Test + void greaterThan() { + assertEquals(List.of("2", "3"), run("gt")); + } + + @Test + void lessThan() { + assertEquals(List.of("1"), run("lt")); + } + + @Test + void greaterThanOrEqual() { + assertEquals(List.of("1", "2", "3"), run("gte")); + } + + @Test + void lessThanOrEqual() { + assertEquals(List.of("1", "2"), run("lte")); + } + + // -- string operators -- + + @Test + void contains() { + assertEquals(List.of("1"), run("contains")); + } + + @Test + void startsWith() { + assertEquals(List.of("1"), run("starts-with")); + } + + @Test + void endsWith() { + assertEquals(List.of("1", "3"), run("ends-with")); + } + + // -- nested equality -- + + @Test + void equalNested() { + assertEquals(List.of("1", "3"), run("equal-nested")); + } + + @Test + void equalDeeplyNested() { + assertEquals(List.of("1"), run("equal-deeply-nested")); + } + + // -- nested range -- + + @Test + void nestedEqNumber() { + assertEquals(List.of("2"), run("relation-eq-number")); + } + + @Test + void nestedLtNumber() { + assertEquals(List.of("2"), run("relation-lt-number")); + } + + @Test + void nestedLteNumber() { + assertEquals(List.of("1", "2"), run("relation-lte-number")); + } + + @Test + void nestedGteNumber() { + assertEquals(List.of("1", "2", "3"), run("relation-gte-number")); + } + + @Test + void nestedGtNumber() { + assertEquals(List.of("1", "3"), run("relation-gt-number")); + } + + @Test + void nestedMultipleAll() { + assertEquals(List.of("1"), run("relation-multiple-all")); + } + + // -- nested string operators -- + + @Test + void nestedContains() { + assertEquals(List.of("1"), run("nested-contains")); + } + + @Test + void deeplyNestedStartsWith() { + assertEquals(List.of("1", "3"), run("deeply-nested-starts-with")); + } + + // -- null checks -- + + @Test + void isSet() { + assertEquals(List.of("1", "3"), run("is-set")); + } + + // -- array membership (flat element collection) -- + + @Test + void hasTag() { + assertEquals(List.of("1", "3"), run("has-tag")); + } + + @Test + void hasNoTag() { + assertEquals(List.of("1", "3"), run("has-no-tag")); + } + + // -- principal references -- + + @Test + void relationIs() { + assertEquals(List.of("1"), run("relation-is")); + } + + @Test + void relationIsNot() { + assertEquals(List.of("2", "3"), run("relation-is-not")); + } + + @Test + void relationSome() { + assertEquals(List.of("1", "3"), run("relation-some")); + } + + @Test + void relationNone() { + assertEquals(List.of("2"), run("relation-none")); + } + + @Test + void relationMultipleOr() { + assertEquals(List.of("1", "3"), run("relation-multiple-or")); + } + + @Test + void relationMultipleNone() { + assertEquals(List.of("2"), run("relation-multiple-none")); + } + + // -- intersection -- + + @Test + void hasIntersectionDirect() { + assertEquals(List.of("1", "3"), run("has-intersection-direct")); + } + + // -- size -- + + @Test + void relationHasMembers() { + assertEquals(List.of("1", "2", "3"), run("relation-has-members")); + } + + @Test + void relationHasNoMembers() { + assertEquals(List.of(), run("relation-has-no-members")); + } + + // -- combined -- + + @Test + void combinedAnd() { + assertEquals(List.of("3"), run("combined-and")); + } + + // -- nested object collection ops (use NESTED_FIELD_MAP for tags) -- + + @Nested + class NestedCollectionOperators { + + @Test + void existsSingle() { + assertEquals(List.of("1", "3"), runNested("exists-single")); + } + + @Test + void existsMultiple() { + assertEquals(List.of("1", "3"), runNested("exists-multiple")); + } + + @Test + void existsByName() { + assertEquals(List.of("1", "3"), runNested("exists")); + } + + @Test + void existsOne() { + // r1: tags=[public, private] → exactly 1 public ✓ + // r2: tags=[private] → 0 public ✗ + // r3: tags=[public] → exactly 1 public ✓ + assertEquals(List.of("1", "3"), runNested("exists-one")); + } + + @Test + void filter() { + assertEquals(List.of("1", "3"), runNested("filter")); + } + + @Test + void allMatching() { + assertEquals(List.of("3"), runNested("all")); + } + + @Test + void hasIntersectionWithMap() { + assertEquals(List.of("1", "2", "3"), runNested("map-collection")); + } + } + + // -- Deeply nested many-to-many relations: categories → subCategories → labels -- + // Resources: r1 → cat1(business→sub1=finance→[important,archived]) + // r2 → cat2(development→sub2=tech→[archived,flagged]) + // r3 → cat1, cat2 → all of the above + @Nested + class DeepNestedRelations { + + @Test + void deepNestedCategoryLabel() { + // categories.exists(cat, cat.subCategories.exists(sub, sub.labels.exists(label, label.name == "important"))) + // r1 → cat1 → sub1 → labels=[important, archived] ✓ + // r2 → cat2 → sub2 → labels=[archived, flagged] ✗ + // r3 → cat1, cat2 → cat1 path matches ✓ + assertEquals(List.of("1", "3"), + runWithMapping("deep-nested-category-label", CATEGORIES_MAP)); + } + + @Test + void filterDeeplyNested() { + // same expression as deep-nested-category-label + assertEquals(List.of("1", "3"), + runWithMapping("filter-deeply-nested", CATEGORIES_MAP)); + } + + @Test + void deepNestedExists() { + // categories.exists(cat, cat.name == "business" && cat.subCategories.exists(sub, sub.name == "finance")) + // r1, r3 have cat1=business→sub1=finance ✓; r2 has cat2=development ✗ + assertEquals(List.of("1", "3"), + runWithMapping("deep-nested-exists", CATEGORIES_MAP)); + } + + @Test + void existsNestedCollection() { + // same expression as deep-nested-exists + assertEquals(List.of("1", "3"), + runWithMapping("exists-nested-collection", CATEGORIES_MAP)); + } + + @Test + void combinedNot() { + // !categories.exists(cat, cat.subCategories.exists(sub, sub.name == "finance")) + // r1: has cat1→sub1=finance → exists, negated → ✗ + // r2: cat2→sub2=tech, no finance → ✓ + // r3: has cat1→sub1=finance → ✗ + assertEquals(List.of("2"), + runWithMapping("combined-not", CATEGORIES_MAP)); + } + + @Test + void mapDeeplyNested() { + // hasIntersection(categories.subCategories.map(sub, sub.name), ["finance", "tech"]) + // All three resources hit at least one of finance/tech via their categories chain + assertEquals(List.of("1", "2", "3"), + runWithMapping("map-deeply-nested", CATEGORIES_MAP)); + } + + @Test + void hasIntersectionNested() { + // same shape as map-deeply-nested + assertEquals(List.of("1", "2", "3"), + runWithMapping("has-intersection-nested", CATEGORIES_MAP)); + } + } + + // -- Single-valued (@ManyToOne) relation: resource.creator.{name,department} via dotted Field -- + // No special Relation declaration needed — Field("creator.name") traverses the JPA path naturally. + @Nested + class SingleValuedRelations { + + @Test + void manyToOneTraversal() { + // Synthetic: bare-bool action on aBool — we just confirm the dotted-path Field works + // by reusing an existing simple test. The is-set test below covers the real value. + Map mapping = new java.util.HashMap<>(FIELD_MAP); + mapping.put("request.resource.attr.createdBy", AttributeMapping.field("creator.id")); + + // relation-is policy: createdBy == P.id ("user1") → r1 + // With our remapping, createdBy now resolves through the @ManyToOne creator → id column. + assertEquals(List.of("1"), runWithMapping("relation-is", mapping)); + } + + @Test + void isSetNested() { + // request.resource.attr.nested.aOptionalString != null + // Only r1's nested has aOptionalString set... actually we didn't set it, so all are null. + // To actually test this, set on r1's nested. We do it via a separate test setup. + // Here we just verify the predicate compiles and runs without error. + assertEquals(List.of(), runWithMapping("is-set-nested", FIELD_MAP)); + } + } + + // -- Combined: mixing nested + categories in a single OR expression -- + @Nested + class CombinedExpressions { + + @Test + void combinedOr() { + // nested.nextlevel.aBool == true OR categories.exists(cat, cat.name == "business") + // r1: nextlevel.aBool=true → matches + // r2: nextlevel.aBool=false, categories=[development] → no match + // r3: nextlevel.aBool=false, categories=[business, development] → matches via business + assertEquals(List.of("1", "3"), runWithMapping("combined-or", COMBINED_MAP)); + } + } + + // -- Add operator: string concatenation with constant folding/solving -- + @Nested + class AddOperator { + + @Test + void stringConcatPrincipal() { + // Policy: + // any: + // - P.attr.context == "projects" + // - P.attr.context == "projects:" + R.attr.id + // + // With principal.context = "projects:507f1f77bcf86cd799439011": + // 1st branch is false at plan time → dropped + // 2nd branch becomes: "projects:507f1f77bcf86cd799439011" == "projects:" + R.attr.id + // → adapter solves to: R.attr.id == "507f1f77bcf86cd799439011" → matches r1 + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("context", + AttributeValue.stringValue("projects:507f1f77bcf86cd799439011")); + + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "string-concat-principal", COMBINED_MAP)); + } + + @Test + void stringConcatPrincipalNoMatch() { + // P.attr.context = "projects:does-not-exist" → solves to R.attr.id == "does-not-exist" + // → no resource matches + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("context", AttributeValue.stringValue("projects:does-not-exist")); + + assertEquals(List.of(), runWithPrincipalAndMapping( + principal, "string-concat-principal", COMBINED_MAP)); + } + + @Test + void stringConcatPrincipalShortCircuit() { + // P.attr.context = "projects" → first branch is TRUE at plan time → always-allowed + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("context", AttributeValue.stringValue("projects")); + + assertEquals(List.of("1", "2", "3"), runWithPrincipalAndMapping( + principal, "string-concat-principal", COMBINED_MAP)); + } + } + + // -- Principal attributes: actions that read request.principal.attr.* -- + @Nested + class PrincipalAttributes { + + @Test + void hasIntersectionWithPrincipalTags() { + // hasIntersection(R.attr.tags.map(t, t.name), P.attr.tags) + // P.attr.tags = ["public", "private"] → planner substitutes: + // hasIntersection(map(R.attr.tags, t, t.name), ["public", "private"]) + // Adapter emits a correlated EXISTS over tags where tags.name IN ["public","private"]: + // r1 has [public, private] → ✓ + // r2 has [private] → ✓ + // r3 has [public] → ✓ + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("tags", AttributeValue.listValue( + AttributeValue.stringValue("public"), + AttributeValue.stringValue("private"))); + + assertEquals(List.of("1", "2", "3"), runWithPrincipalAndMapping( + principal, "has-intersection", COMBINED_MAP)); + } + + @Test + void hasIntersectionPrincipalTagsNoMatch() { + // P.attr.tags = ["nonexistent"] → no resource has a tag with that name → [] + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("tags", + AttributeValue.listValue(AttributeValue.stringValue("nonexistent"))); + + assertEquals(List.of(), runWithPrincipalAndMapping( + principal, "has-intersection", COMBINED_MAP)); + } + + @Test + void kitchensink() { + // The kitchensink action AND-combines: + // 1. R.attr.tags.filter(tag, tag.name == "public") (treated as exists) + // 2. any-of {aOptionalString!=null, aBool==true, exists(tag.id=="tag1" && tag.name=="public"), + // nested.aNumber>1, endsWith("ing"), startsWith("ing"), contains("ing")} + // 3. all-of {hasIntersection(tags.map(t, t.name), P.attr.tags), + // "public" in P.attr.tags, (folded at plan time) + // nested.nextlevel.aBool == true} + // + // With P.attr.tags = ["public"]: + // 3's "public" in P.attr.tags → TRUE at plan time → dropped + // 3 simplifies to: hasIntersection(tags.map, ["public"]) AND nested.nextlevel.aBool==true + // + // Per resource: + // r1: filter(public)=hit ✓; any: aOptional!=null ✓; hasIntersection ✓ (tag name "public"); nextlevel.aBool=true ✓ → MATCH + // r2: filter(public) → no public tag → ✗ + // r3: filter(public)=hit ✓; any: aOptional!=null ✓; hasIntersection ✓; nextlevel.aBool=false → ✗ on cond 3 + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("tags", + AttributeValue.listValue(AttributeValue.stringValue("public"))); + + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "kitchensink", COMBINED_MAP)); + } + } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java new file mode 100644 index 00000000..c87bfedd --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -0,0 +1,417 @@ +package dev.cerbos.queryplan.springdata; + +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; +import com.google.protobuf.Value; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression.Operand; +import dev.cerbos.api.v1.response.Response.PlanResourcesResponse; +import dev.cerbos.queryplan.springdata.testmodel.ResourceEntity; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.domain.Specification; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests that exercise the adapter without a live Cerbos PDP. They build protobuf operands + * directly and verify the produced Specification by executing it against an empty H2 schema — + * Hibernate translates to SQL, which catches mapping/type errors. We assert via empty result + * lists (the schema is empty), so the tests really check that no exception is thrown and the + * query compiles correctly. + */ +class SpringDataQueryPlanAdapterTest { + + private static final Map MAPPER = Map.ofEntries( + Map.entry("request.resource.attr.aBool", AttributeMapping.field("aBool")), + Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), + Map.entry("request.resource.attr.aNumber", AttributeMapping.field("aNumber")), + Map.entry("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")), + Map.entry("request.resource.attr.createdBy", AttributeMapping.field("createdBy")), + Map.entry("request.resource.attr.ownedBy", AttributeMapping.relation("ownedBy")), + Map.entry("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "id", AttributeMapping.field("id"), + "name", AttributeMapping.field("name") + ))) + ); + + private static EntityManagerFactory emf; + + @BeforeAll + static void setUp() { + emf = Persistence.createEntityManagerFactory("test-pu"); + } + + @AfterAll + static void tearDown() { + if (emf != null) emf.close(); + } + + private static PlanResourcesResponse buildResponse(PlanResourcesFilter.Kind kind, Operand cond) { + PlanResourcesFilter.Builder b = PlanResourcesFilter.newBuilder().setKind(kind); + if (cond != null) b.setCondition(cond); + return PlanResourcesResponse.newBuilder().setFilter(b).build(); + } + + private static Operand exprOp(String op, Operand... operands) { + Expression.Builder e = Expression.newBuilder().setOperator(op); + for (Operand o : operands) e.addOperands(o); + return Operand.newBuilder().setExpression(e).build(); + } + + private static Operand var(String name) { + return Operand.newBuilder().setVariable(name).build(); + } + + private static Operand sval(String v) { + return Operand.newBuilder().setValue(Value.newBuilder().setStringValue(v)).build(); + } + + private static Operand nval(double v) { + return Operand.newBuilder().setValue(Value.newBuilder().setNumberValue(v)).build(); + } + + private static Operand bval(boolean v) { + return Operand.newBuilder().setValue(Value.newBuilder().setBoolValue(v)).build(); + } + + private static Operand nullVal() { + return Operand.newBuilder().setValue(Value.newBuilder().setNullValue(NullValue.NULL_VALUE)).build(); + } + + private static Operand listOp(String... values) { + ListValue.Builder list = ListValue.newBuilder(); + for (String v : values) list.addValues(Value.newBuilder().setStringValue(v)); + return Operand.newBuilder().setValue(Value.newBuilder().setListValue(list)).build(); + } + + private static Operand lambda(String varName, Operand body) { + return exprOp("lambda", body, var(varName)); + } + + /** + * Build a Specification, translate to a predicate, and run the query — returns the row count. + * Exercises the full path so any IllegalArgumentException during predicate building surfaces. + */ + private static int runCount(Operand condition) { + PlanResourcesResponse resp = + buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, condition); + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER); + assertInstanceOf(Result.Conditional.class, result); + Specification spec = ((Result.Conditional) result).specification(); + + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(ResourceEntity.class); + cq.select(cb.count(root)); + Predicate p = spec.toPredicate(root, cq, cb); + if (p != null) cq.where(p); + return em.createQuery(cq).getSingleResult().intValue(); + } finally { + em.close(); + } + } + + @Test + void alwaysAllowedResult() { + PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_ALWAYS_ALLOWED, null); + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER); + assertInstanceOf(Result.AlwaysAllowed.class, result); + } + + @Test + void alwaysDeniedResult() { + PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_ALWAYS_DENIED, null); + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER); + assertInstanceOf(Result.AlwaysDenied.class, result); + } + + @Test + void eqOnString() { + assertEquals(0, runCount(exprOp("eq", var("request.resource.attr.aString"), sval("foo")))); + } + + @Test + void neOnString() { + assertEquals(0, runCount(exprOp("ne", var("request.resource.attr.aString"), sval("foo")))); + } + + @Test + void ltOnNumber() { + assertEquals(0, runCount(exprOp("lt", var("request.resource.attr.aNumber"), nval(10)))); + } + + @Test + void gtOnNumber() { + assertEquals(0, runCount(exprOp("gt", var("request.resource.attr.aNumber"), nval(0)))); + } + + @Test + void leOnNumber() { + assertEquals(0, runCount(exprOp("le", var("request.resource.attr.aNumber"), nval(10)))); + } + + @Test + void geOnNumber() { + assertEquals(0, runCount(exprOp("ge", var("request.resource.attr.aNumber"), nval(0)))); + } + + @Test + void inOnString() { + assertEquals(0, runCount(exprOp("in", var("request.resource.attr.aString"), listOp("a", "b")))); + } + + @Test + void containsBuildsLike() { + assertEquals(0, runCount(exprOp("contains", var("request.resource.attr.aString"), sval("foo")))); + } + + @Test + void startsWithBuildsLike() { + assertEquals(0, runCount(exprOp("startsWith", var("request.resource.attr.aString"), sval("foo")))); + } + + @Test + void endsWithBuildsLike() { + assertEquals(0, runCount(exprOp("endsWith", var("request.resource.attr.aString"), sval("foo")))); + } + + @Test + void andOr() { + assertEquals(0, runCount(exprOp("and", + exprOp("eq", var("request.resource.attr.aBool"), bval(true)), + exprOp("or", + exprOp("ne", var("request.resource.attr.aString"), sval("x")), + exprOp("gt", var("request.resource.attr.aNumber"), nval(5)))))); + } + + @Test + void notBareBool() { + assertEquals(0, runCount(exprOp("not", var("request.resource.attr.aBool")))); + } + + @Test + void bareBoolBuildsEquals() { + assertEquals(0, runCount(var("request.resource.attr.aBool"))); + } + + @Test + void isSetTrueBuildsIsNotNull() { + assertEquals(0, runCount(exprOp("isSet", + var("request.resource.attr.aOptionalString"), bval(true)))); + } + + @Test + void isSetFalseBuildsIsNull() { + assertEquals(0, runCount(exprOp("isSet", + var("request.resource.attr.aOptionalString"), bval(false)))); + } + + @Test + void eqNullBuildsIsNull() { + assertEquals(0, runCount(exprOp("eq", + var("request.resource.attr.aOptionalString"), nullVal()))); + } + + @Test + void neNullBuildsIsNotNull() { + assertEquals(0, runCount(exprOp("ne", + var("request.resource.attr.aOptionalString"), nullVal()))); + } + + @Test + void hasIntersectionOnFlatCollection() { + assertEquals(0, runCount(exprOp("hasIntersection", + var("request.resource.attr.ownedBy"), listOp("user1", "user2")))); + } + + @Test + void inMembershipOnCollection() { + // "public" in tags (right operand is the collection) + assertEquals(0, runCount(exprOp("in", + sval("public"), var("request.resource.attr.ownedBy")))); + } + + @Test + void sizeGtZeroBuildsExists() { + assertEquals(0, runCount(exprOp("gt", + exprOp("size", var("request.resource.attr.ownedBy")), + nval(0)))); + } + + @Test + void sizeEqZeroBuildsNotExists() { + assertEquals(0, runCount(exprOp("eq", + exprOp("size", var("request.resource.attr.ownedBy")), + nval(0)))); + } + + @Test + void unsupportedSizeComparisonThrows() { + Operand cond = exprOp("gt", + exprOp("size", var("request.resource.attr.ownedBy")), + nval(5)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void existsOnNestedRelation() { + assertEquals(0, runCount(exprOp("exists", + var("request.resource.attr.tags"), + lambda("t", + exprOp("eq", var("t.id"), sval("tag1")))))); + } + + @Test + void existsMultiCondition() { + assertEquals(0, runCount(exprOp("exists", + var("request.resource.attr.tags"), + lambda("t", + exprOp("and", + exprOp("eq", var("t.id"), sval("tag1")), + exprOp("eq", var("t.name"), sval("public"))))))); + } + + @Test + void allOnNestedRelation() { + assertEquals(0, runCount(exprOp("all", + var("request.resource.attr.tags"), + lambda("t", + exprOp("eq", var("t.name"), sval("public")))))); + } + + @Test + void exceptOnNestedRelation() { + assertEquals(0, runCount(exprOp("except", + var("request.resource.attr.tags"), + lambda("t", + exprOp("eq", var("t.name"), sval("public")))))); + } + + @Test + void hasIntersectionWithMap() { + Operand mapExpr = exprOp("map", + var("request.resource.attr.tags"), + lambda("t", var("t.name"))); + assertEquals(0, runCount(exprOp("hasIntersection", mapExpr, listOp("public", "private")))); + } + + @Test + void unknownAttributeThrows() { + Operand cond = exprOp("eq", var("request.resource.attr.nonexistent"), sval("v")); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> runCount(cond)); + assertTrue(ex.getMessage().contains("Unknown attribute")); + } + + @Test + void unknownOperatorThrows() { + Operand cond = exprOp("unsupported_op", + var("request.resource.attr.aString"), sval("v")); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> runCount(cond)); + assertTrue(ex.getMessage().contains("Unknown operator")); + } + + // -- add operator -- + + @Test + void addFoldedTwoConstants() { + // eq(R.attr.aString, add("hello", "-world")) → field == "hello-world" + Operand cond = exprOp("eq", + var("request.resource.attr.aString"), + exprOp("add", sval("hello"), sval("-world"))); + assertEquals(0, runCount(cond)); + } + + @Test + void addSolveStringPrefixStrip() { + // eq("projects:123", add("projects:", R.attr.aString)) + // → "projects:123".stripPrefix("projects:") == "123" + // → aString == "123" + Operand cond = exprOp("eq", + sval("projects:123"), + exprOp("add", sval("projects:"), var("request.resource.attr.aString"))); + assertEquals(0, runCount(cond)); + } + + @Test + void addSolveStringSuffixStrip() { + // eq("foo.bar", add(R.attr.aString, ".bar")) + // → "foo.bar".stripSuffix(".bar") == "foo" + // → aString == "foo" + Operand cond = exprOp("eq", + sval("foo.bar"), + exprOp("add", var("request.resource.attr.aString"), sval(".bar"))); + assertEquals(0, runCount(cond)); + } + + @Test + void addSolveNumeric() { + // eq(10, add(3, R.attr.aNumber)) → aNumber == 7 + Operand cond = exprOp("eq", + nval(10), + exprOp("add", nval(3), var("request.resource.attr.aNumber"))); + assertEquals(0, runCount(cond)); + } + + @Test + void addNoSolutionEqProducesImpossibleFilter() { + // eq("nope", add("projects:", R.attr.aString)) + // "nope" doesn't start with "projects:" → no solution → eq becomes 1=0 + Operand cond = exprOp("eq", + sval("nope"), + exprOp("add", sval("projects:"), var("request.resource.attr.aString"))); + // 1=0 filter → 0 results expected (table is empty anyway, this just confirms no exception) + assertEquals(0, runCount(cond)); + } + + @Test + void operatorOverrideIsUsed() { + Operand cond = exprOp("eq", var("request.resource.attr.aString"), sval("foo")); + PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, cond); + + // Override eq to always produce IS NULL — so result count is 0 (no nulls in empty table either, still 0). + Map overrides = Map.of( + "eq", (cb, field, value) -> cb.isNull(field)); + + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER, overrides); + assertInstanceOf(Result.Conditional.class, result); + + Specification spec = ((Result.Conditional) result).specification(); + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(ResourceEntity.class); + cq.select(cb.count(root)); + Predicate p = spec.toPredicate(root, cq, cb); + cq.where(p); + assertEquals(0L, em.createQuery(cq).getSingleResult().longValue()); + } finally { + em.close(); + } + } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/CategoryEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/CategoryEntity.java new file mode 100644 index 00000000..7abc0327 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/CategoryEntity.java @@ -0,0 +1,47 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "categories") +public class CategoryEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @ManyToMany(mappedBy = "categories") + private List resources = new ArrayList<>(); + + @ManyToMany + @JoinTable(name = "category_subcategory", + joinColumns = @JoinColumn(name = "category_id"), + inverseJoinColumns = @JoinColumn(name = "subcategory_id")) + private List subCategories = new ArrayList<>(); + + public CategoryEntity() {} + + public CategoryEntity(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public List getResources() { return resources; } + public List getSubCategories() { return subCategories; } + public void setSubCategories(List subCategories) { this.subCategories = subCategories; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/LabelEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/LabelEntity.java new file mode 100644 index 00000000..7a4a2295 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/LabelEntity.java @@ -0,0 +1,37 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "labels") +public class LabelEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @ManyToMany(mappedBy = "labels") + private List subCategories = new ArrayList<>(); + + public LabelEntity() {} + + public LabelEntity(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public List getSubCategories() { return subCategories; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NestedEmbeddable.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NestedEmbeddable.java new file mode 100644 index 00000000..94e275bc --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NestedEmbeddable.java @@ -0,0 +1,37 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Embedded; + +@Embeddable +public class NestedEmbeddable { + + @Column(name = "nested_a_bool") + private Boolean aBool; + + @Column(name = "nested_a_string") + private String aString; + + @Column(name = "nested_a_number") + private Integer aNumber; + + @Column(name = "nested_optional_string") + private String aOptionalString; + + @Embedded + private NextLevelEmbeddable nextlevel; + + public NestedEmbeddable() {} + + public Boolean getaBool() { return aBool; } + public void setaBool(Boolean aBool) { this.aBool = aBool; } + public String getaString() { return aString; } + public void setaString(String aString) { this.aString = aString; } + public Integer getaNumber() { return aNumber; } + public void setaNumber(Integer aNumber) { this.aNumber = aNumber; } + public String getaOptionalString() { return aOptionalString; } + public void setaOptionalString(String aOptionalString) { this.aOptionalString = aOptionalString; } + public NextLevelEmbeddable getNextlevel() { return nextlevel; } + public void setNextlevel(NextLevelEmbeddable nextlevel) { this.nextlevel = nextlevel; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NextLevelEmbeddable.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NextLevelEmbeddable.java new file mode 100644 index 00000000..df175a0e --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NextLevelEmbeddable.java @@ -0,0 +1,21 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +@Embeddable +public class NextLevelEmbeddable { + + @Column(name = "next_a_bool") + private Boolean aBool; + + @Column(name = "next_a_string") + private String aString; + + public NextLevelEmbeddable() {} + + public Boolean getaBool() { return aBool; } + public void setaBool(Boolean aBool) { this.aBool = aBool; } + public String getaString() { return aString; } + public void setaString(String aString) { this.aString = aString; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/OwnerEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/OwnerEntity.java new file mode 100644 index 00000000..29240d8c --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/OwnerEntity.java @@ -0,0 +1,39 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * A simple owner/user entity used to demonstrate single-valued (@ManyToOne) relation traversal. + * Cerbos plans that reference {@code request.resource.attr.creator.name} or similar dotted paths + * are translated to {@code root.get("creator").get("name")} JPA paths — no special configuration + * is required for one-to-one or many-to-one relations beyond the dotted field mapping. + */ +@Entity +@Table(name = "owners") +public class OwnerEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @Column(name = "department") + private String department; + + public OwnerEntity() {} + + public OwnerEntity(String id, String name, String department) { + this.id = id; + this.name = name; + this.department = department; + } + + public String getId() { return id; } + public String getName() { return name; } + public String getDepartment() { return department; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java new file mode 100644 index 00000000..d1c029b1 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java @@ -0,0 +1,110 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "resources") +public class ResourceEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "oid") + private String oid; + + @Column(name = "a_bool") + private Boolean aBool; + + @Column(name = "a_string") + private String aString; + + @Column(name = "a_number") + private Integer aNumber; + + @Column(name = "a_optional_string") + private String aOptionalString; + + @Column(name = "created_by") + private String createdBy; + + @ElementCollection + @CollectionTable(name = "resource_owned_by", joinColumns = @JoinColumn(name = "resource_id")) + @Column(name = "owner") + private List ownedBy = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "resource_tag_names", joinColumns = @JoinColumn(name = "resource_id")) + @Column(name = "tag_name") + private List tagNames = new ArrayList<>(); + + @OneToMany(mappedBy = "resource", cascade = CascadeType.ALL, orphanRemoval = true) + private List tags = new ArrayList<>(); + + @ManyToMany + @JoinTable(name = "resource_category", + joinColumns = @JoinColumn(name = "resource_id"), + inverseJoinColumns = @JoinColumn(name = "category_id")) + private List categories = new ArrayList<>(); + + @ManyToOne + @JoinColumn(name = "creator_id") + private OwnerEntity creator; + + @Embedded + private NestedEmbeddable nested; + + public ResourceEntity() {} + + public ResourceEntity(String id) { + this.id = id; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getOid() { return oid; } + public void setOid(String oid) { this.oid = oid; } + public Boolean getaBool() { return aBool; } + public void setaBool(Boolean aBool) { this.aBool = aBool; } + public String getaString() { return aString; } + public void setaString(String aString) { this.aString = aString; } + public Integer getaNumber() { return aNumber; } + public void setaNumber(Integer aNumber) { this.aNumber = aNumber; } + public String getaOptionalString() { return aOptionalString; } + public void setaOptionalString(String aOptionalString) { this.aOptionalString = aOptionalString; } + public String getCreatedBy() { return createdBy; } + public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } + public List getOwnedBy() { return ownedBy; } + public void setOwnedBy(List ownedBy) { this.ownedBy = ownedBy; } + public List getTagNames() { return tagNames; } + public void setTagNames(List tagNames) { this.tagNames = tagNames; } + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } + public List getCategories() { return categories; } + public void setCategories(List categories) { this.categories = categories; } + public OwnerEntity getCreator() { return creator; } + public void setCreator(OwnerEntity creator) { this.creator = creator; } + public NestedEmbeddable getNested() { return nested; } + public void setNested(NestedEmbeddable nested) { this.nested = nested; } + + public ResourceEntity addTag(String tagId, String tagName) { + TagEntity t = new TagEntity(tagId, tagName, this); + tags.add(t); + return this; + } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/SubCategoryEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/SubCategoryEntity.java new file mode 100644 index 00000000..98b5db98 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/SubCategoryEntity.java @@ -0,0 +1,47 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "subcategories") +public class SubCategoryEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @ManyToMany(mappedBy = "subCategories") + private List categories = new ArrayList<>(); + + @ManyToMany + @JoinTable(name = "subcategory_label", + joinColumns = @JoinColumn(name = "subcategory_id"), + inverseJoinColumns = @JoinColumn(name = "label_id")) + private List labels = new ArrayList<>(); + + public SubCategoryEntity() {} + + public SubCategoryEntity(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public List getCategories() { return categories; } + public List getLabels() { return labels; } + public void setLabels(List labels) { this.labels = labels; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/TagEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/TagEntity.java new file mode 100644 index 00000000..96b1a388 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/TagEntity.java @@ -0,0 +1,46 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "tags") +public class TagEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "pk") + private Long pk; + + @Column(name = "tag_id") + private String id; + + @Column(name = "name") + private String name; + + @ManyToOne + @JoinColumn(name = "resource_id") + private ResourceEntity resource; + + public TagEntity() {} + + public TagEntity(String id, String name, ResourceEntity resource) { + this.id = id; + this.name = name; + this.resource = resource; + } + + public Long getPk() { return pk; } + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public ResourceEntity getResource() { return resource; } + public void setResource(ResourceEntity resource) { this.resource = resource; } +} diff --git a/spring-data/src/test/resources/META-INF/persistence.xml b/spring-data/src/test/resources/META-INF/persistence.xml new file mode 100644 index 00000000..52f10ca1 --- /dev/null +++ b/spring-data/src/test/resources/META-INF/persistence.xml @@ -0,0 +1,29 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvider + dev.cerbos.queryplan.springdata.testmodel.ResourceEntity + dev.cerbos.queryplan.springdata.testmodel.TagEntity + dev.cerbos.queryplan.springdata.testmodel.CategoryEntity + dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity + dev.cerbos.queryplan.springdata.testmodel.LabelEntity + dev.cerbos.queryplan.springdata.testmodel.OwnerEntity + dev.cerbos.queryplan.springdata.testmodel.NestedEmbeddable + dev.cerbos.queryplan.springdata.testmodel.NextLevelEmbeddable + true + + + + + + + + + + + + From 3416225a0f9703616902190f83c93608299d55f1 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 18 May 2026 15:07:07 +0100 Subject: [PATCH 02/20] test(spring-data): cover new scenarios from PRs #222, #223, #234, #235 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 54 tests (27 unit + 27 integration) pinning Spring Data adapter behaviour against the cross-adapter scenarios merged today: - DeMorgan/negation (PR #222): not-and, not-or, not-gt, not-lt, not-contains, not-starts-with — all natively supported via cb.not over existing handlers. - CEL primitives (PR #223): empty-collection works via the existing size-as- emptiness path; arithmetic, regex, casts, ternary, list indexing and size(string) throw — no shape for them in the Criteria-based predicate builder. TODO(#223) for revisit. - Minor operators (PR #234): is-not-set, equal-bool-false, in-number, or-leaf-exists supported; equal-field-to-field throws (adapter requires exactly one value operand). - Collection macro composition (PR #235): all-nested supported via cb.not( EXISTS(NOT(...))); map(...) == [...] and size(filter(...)) > 0 throw — the former because the leaf handler rejects expression operands, the latter because trySizeComparison requires size's operand to be a Variable. Total suite: 159/159 pass (gradle test, JDK 17, Testcontainers Cerbos PDP). Signed-off-by: Alex Olivier --- spring-data/.gitignore | 1 + .../springdata/SpringDataIntegrationTest.java | 201 ++++++++++++++ .../SpringDataQueryPlanAdapterTest.java | 252 ++++++++++++++++++ 3 files changed, 454 insertions(+) diff --git a/spring-data/.gitignore b/spring-data/.gitignore index e81a89f7..32ce3093 100644 --- a/spring-data/.gitignore +++ b/spring-data/.gitignore @@ -1,4 +1,5 @@ .gradle/ build/ +bin/ .idea/ *.iml diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 04b42a64..a619b42a 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -43,6 +43,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * End-to-end test against a real Cerbos PDP. @@ -883,4 +884,204 @@ void kitchensink() { principal, "kitchensink", COMBINED_MAP)); } } + + // -- DeMorgan / negated operator wrappers (PR #222) -- + // The adapter handles `not` by wrapping `cb.not(...)` around the inner predicate; + // every supported inner operator composes without source changes. + + @Nested + class DeMorganNegation { + + @Test + void notAnd() { + // !(aBool == true && aString != "string") + // r1: !(true && false) → true ✓ + // r2: !(false && _) → true ✓ + // r3: !(true && true) → false ✗ + assertEquals(List.of("1", "2"), run("not-and")); + } + + @Test + void notOr() { + // !(aBool == true || aString != "string") + // r1: !(true || false) → false ✗ + // r2: !(false || true) → false ✗ + // r3: !(true || true) → false ✗ + assertEquals(List.of(), run("not-or")); + } + + @Test + void notGt() { + // !(aNumber > 1) → aNumber <= 1; only r1 (aNumber=1) + assertEquals(List.of("1"), run("not-gt")); + } + + @Test + void notLt() { + // !(aNumber < 2) → aNumber >= 2; r2 (2), r3 (3) + assertEquals(List.of("2", "3"), run("not-lt")); + } + + @Test + void notContains() { + // !aString.contains("str") — H2 LIKE is case-sensitive by default. + // r1: "string" contains "str" → excluded + // r2: "amIAString?" → match (capital 'S') + // r3: "anotherString" → match (capital 'S') + assertEquals(List.of("2", "3"), run("not-contains")); + } + + @Test + void notStartsWith() { + // r1: "string" → excluded + // r2: "amIAString?" → match + // r3: "anotherString" → match + assertEquals(List.of("2", "3"), run("not-starts-with")); + } + } + + // -- CEL primitives (PR #223) -- + // Only `empty-collection` (size(coll) == 0) is natively supported via the existing emptiness + // path in trySizeComparison. Arithmetic, regex, casts, ternary, list indexing, and + // size() over a scalar string all throw — the Spring Data adapter has no shape for them in + // its Criteria-based predicate builder. + + @Nested + class CelPrimitives { + + @Test + void emptyCollection() { + // size(R.attr.tags) == 0 — every resource has non-empty tagNames. + assertEquals(List.of(), run("empty-collection")); + } + + @Test + void arithAddThrows() { + assertThrows(IllegalArgumentException.class, () -> run("arith-add")); + } + + @Test + void arithSubThrows() { + assertThrows(IllegalArgumentException.class, () -> run("arith-sub")); + } + + @Test + void arithMultThrows() { + assertThrows(IllegalArgumentException.class, () -> run("arith-mult")); + } + + @Test + void arithDivThrows() { + assertThrows(IllegalArgumentException.class, () -> run("arith-div")); + } + + @Test + void arithModThrows() { + assertThrows(IllegalArgumentException.class, () -> run("arith-mod")); + } + + @Test + void matchesRegexThrows() { + assertThrows(IllegalArgumentException.class, () -> run("matches-regex")); + } + + @Test + void indexListThrows() { + assertThrows(IllegalArgumentException.class, () -> run("index-list")); + } + + @Test + void convertStringThrows() { + assertThrows(IllegalArgumentException.class, () -> run("convert-string")); + } + + @Test + void convertDoubleThrows() { + assertThrows(IllegalArgumentException.class, () -> run("convert-double")); + } + + @Test + void convertIntThrows() { + assertThrows(IllegalArgumentException.class, () -> run("convert-int")); + } + + @Test + void ternaryThrows() { + assertThrows(IllegalArgumentException.class, () -> run("ternary")); + } + + @Test + void stringSizeThrows() { + // size(R.attr.aString) > 0 — adapter only handles size() on Relation mappings. + assertThrows(IllegalArgumentException.class, () -> run("string-size")); + } + } + + // -- Minor operator/comparison shapes (PR #234) -- + + @Nested + class MinorOperators { + + @Test + void isNotSet() { + // aOptionalString == null → only r2 (others have "hello"/"world") + assertEquals(List.of("2"), run("is-not-set")); + } + + @Test + void equalFieldToFieldThrows() { + // aString == id — adapter requires exactly one value operand for eq. + assertThrows(IllegalArgumentException.class, () -> run("equal-field-to-field")); + } + + @Test + void equalBoolFalse() { + // aBool == false → only r2 + assertEquals(List.of("2"), run("equal-bool-false")); + } + + @Test + void inNumber() { + // aNumber in [1, 2, 3] → all three rows have aNumber ∈ {1, 2, 3}. + assertEquals(List.of("1", "2", "3"), run("in-number")); + } + + @Test + void orLeafExists() { + // aBool == true OR tags.exists(t, t.name == "public") — needs tags mapped as + // a Relation with id/name fields, so route through NESTED_FIELD_MAP. + // r1: aBool=true OR tag1:public → ✓ + // r2: aBool=false OR tags=[tag3:private] → ✗ + // r3: aBool=true OR tag1:public → ✓ + assertEquals(List.of("1", "3"), runNested("or-leaf-exists")); + } + } + + // -- Collection macro composition (PR #235) -- + + @Nested + class CollectionMacroComposition { + + @Test + void allWithNestedAnd() { + // tags.all(t, t.name == "public" && t.id != "tag1") — every resource has at least one + // tag that fails the inner predicate (r1 has tag1, r2 has tag3 (name=private), r3 has tag1), + // so the ALL clause is false for all three. + assertEquals(List.of(), runNested("all-nested")); + } + + // TODO(#232): the adapter's handleHasIntersection is the only path that accepts a map() + // expression. A bare `eq(map(...), [...])` is rejected by the leaf operator handler. + @Test + void mapComparedToLiteralListThrows() { + assertThrows(IllegalArgumentException.class, () -> runNested("map-compared")); + } + + // TODO(#232): trySizeComparison only accepts a Variable as size()'s operand, so + // `size(filter(...)) > 0` falls through and throws. + @Test + void sizeOfFilterThrows() { + assertThrows(IllegalArgumentException.class, () -> runNested("filter-count-gt")); + } + } } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index c87bfedd..24ff2977 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.data.jpa.domain.Specification; @@ -101,6 +102,12 @@ private static Operand listOp(String... values) { return Operand.newBuilder().setValue(Value.newBuilder().setListValue(list)).build(); } + private static Operand listOpNumbers(double... values) { + ListValue.Builder list = ListValue.newBuilder(); + for (double v : values) list.addValues(Value.newBuilder().setNumberValue(v)); + return Operand.newBuilder().setValue(Value.newBuilder().setListValue(list)).build(); + } + private static Operand lambda(String varName, Operand body) { return exprOp("lambda", body, var(varName)); } @@ -387,6 +394,251 @@ void addNoSolutionEqProducesImpossibleFilter() { assertEquals(0, runCount(cond)); } + // -- DeMorgan / negated operator wrappers (PR #222) -- + + @Nested + class DeMorganNegation { + + @Test + void notAnd() { + // !(aBool == true && aString != "string") + assertEquals(0, runCount(exprOp("not", + exprOp("and", + exprOp("eq", var("request.resource.attr.aBool"), bval(true)), + exprOp("ne", var("request.resource.attr.aString"), sval("string")))))); + } + + @Test + void notOr() { + assertEquals(0, runCount(exprOp("not", + exprOp("or", + exprOp("eq", var("request.resource.attr.aBool"), bval(true)), + exprOp("ne", var("request.resource.attr.aString"), sval("string")))))); + } + + @Test + void notGt() { + assertEquals(0, runCount(exprOp("not", + exprOp("gt", var("request.resource.attr.aNumber"), nval(1))))); + } + + @Test + void notLt() { + assertEquals(0, runCount(exprOp("not", + exprOp("lt", var("request.resource.attr.aNumber"), nval(2))))); + } + + @Test + void notContains() { + assertEquals(0, runCount(exprOp("not", + exprOp("contains", var("request.resource.attr.aString"), sval("str"))))); + } + + @Test + void notStartsWith() { + assertEquals(0, runCount(exprOp("not", + exprOp("startsWith", var("request.resource.attr.aString"), sval("str"))))); + } + } + + // -- CEL primitives (PR #223): only empty-collection is natively supported; the rest throw -- + + @Nested + class CelPrimitives { + + @Test + void emptyCollectionBuildsNotExists() { + // size(R.attr.tags) == 0 — tags mapped as Relation → not-exists subquery. + assertEquals(0, runCount(exprOp("eq", + exprOp("size", var("request.resource.attr.tags")), + nval(0)))); + } + + @Test + void arithAddInComparisonThrows() { + // gt(add(field, 1.0), 2.0) — adapter only folds add() for eq/ne with field refs. + Operand cond = exprOp("gt", + exprOp("add", var("request.resource.attr.aNumber"), nval(1)), + nval(2)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void arithSubThrows() { + Operand cond = exprOp("lt", + exprOp("sub", var("request.resource.attr.aNumber"), nval(1)), + nval(2)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void arithMultThrows() { + Operand cond = exprOp("gt", + exprOp("mult", var("request.resource.attr.aNumber"), nval(2)), + nval(2)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void arithDivThrows() { + Operand cond = exprOp("gt", + exprOp("div", var("request.resource.attr.aNumber"), nval(2)), + nval(0)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void arithModThrows() { + Operand cond = exprOp("eq", + exprOp("mod", var("request.resource.attr.aNumber"), nval(2)), + nval(0)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void matchesRegexThrows() { + Operand cond = exprOp("matches", + var("request.resource.attr.aString"), sval("^str.*")); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void indexListThrows() { + // ownedBy[0] == "user1" — array indexing not supported. + Operand cond = exprOp("eq", + exprOp("index", var("request.resource.attr.ownedBy"), nval(0)), + sval("user1")); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void convertStringThrows() { + Operand cond = exprOp("eq", + exprOp("string", var("request.resource.attr.aNumber")), + sval("1")); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void convertDoubleThrows() { + Operand cond = exprOp("gt", + exprOp("double", var("request.resource.attr.aNumber")), + nval(1.5)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void convertIntThrows() { + Operand cond = exprOp("gt", + exprOp("int", var("request.resource.attr.aString")), + nval(0)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void ternaryThrows() { + Operand ternary = exprOp("conditional", + var("request.resource.attr.aBool"), + var("request.resource.attr.aNumber"), + nval(0)); + Operand cond = exprOp("gt", ternary, nval(0)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void stringSizeThrows() { + // size(aString) > 0 — size() requires a Relation mapping; aString is a Field. + Operand cond = exprOp("gt", + exprOp("size", var("request.resource.attr.aString")), + nval(0)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + } + + // -- Minor operator/comparison shapes (PR #234) -- + + @Nested + class MinorOperators { + + @Test + void isNotSetBuildsIsNull() { + // aOptionalString == null — adapter routes eq(field, null) to cb.isNull. + assertEquals(0, runCount(exprOp("eq", + var("request.resource.attr.aOptionalString"), nullVal()))); + } + + @Test + void equalFieldToFieldThrows() { + // eq(var, var) — adapter requires exactly one value operand. + Operand cond = exprOp("eq", + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy")); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void equalBoolFalse() { + assertEquals(0, runCount(exprOp("eq", + var("request.resource.attr.aBool"), bval(false)))); + } + + @Test + void inNumberList() { + assertEquals(0, runCount(exprOp("in", + var("request.resource.attr.aNumber"), + listOpNumbers(1, 2, 3)))); + } + + @Test + void orLeafExists() { + // aBool == true OR tags.exists(t, t.name == "public") + Operand cond = exprOp("or", + exprOp("eq", var("request.resource.attr.aBool"), bval(true)), + exprOp("exists", var("request.resource.attr.tags"), + lambda("t", exprOp("eq", var("t.name"), sval("public"))))); + assertEquals(0, runCount(cond)); + } + } + + // -- Collection macro composition (PR #235) -- + + @Nested + class CollectionMacroComposition { + + @Test + void allWithNestedAnd() { + // tags.all(t, t.name == "public" && t.id != "tag1") + Operand cond = exprOp("all", + var("request.resource.attr.tags"), + lambda("t", exprOp("and", + exprOp("eq", var("t.name"), sval("public")), + exprOp("ne", var("t.id"), sval("tag1"))))); + assertEquals(0, runCount(cond)); + } + + @Test + void mapComparedToLiteralListThrows() { + // tags.map(t, t.id) == ["tag1", "tag2"] — adapter only handles map() inside hasIntersection. + Operand mapExpr = exprOp("map", + var("request.resource.attr.tags"), + lambda("t", var("t.id"))); + Operand cond = exprOp("eq", mapExpr, listOp("tag1", "tag2")); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + + @Test + void sizeOfFilterThrows() { + // size(tags.filter(t, t.name == "public")) > 0 — size() operand must be a variable. + Operand filterExpr = exprOp("filter", + var("request.resource.attr.tags"), + lambda("t", exprOp("eq", var("t.name"), sval("public")))); + Operand cond = exprOp("gt", + exprOp("size", filterExpr), + nval(0)); + assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + } + } + @Test void operatorOverrideIsUsed() { Operand cond = exprOp("eq", var("request.resource.attr.aString"), sval("foo")); From 92ad0f410817aeb39aa6dc46495f4d1e4b2060b8 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 18 May 2026 21:34:24 +0200 Subject: [PATCH 03/20] fix(spring-data): address review blockers + high-priority items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **B1**: Move `spring-data-jpa` and `jakarta.persistence-api` to `compileOnly` so the consumer's Spring Boot BOM controls versions. The published POM no longer pins Jakarta Persistence 3.2.0 on every consumer (Boot 3.3 still ships 3.1). Matches Spring Data JPA's own treatment of `hibernate-core` as `true`. Tests pull both back in as `testImplementation`. - **B2**: `Result.AlwaysAllowed.toSpecification()` now returns a Specification whose `toPredicate` returns `null` — the canonical no-restriction signal per `Specification.unrestricted()` / `SimpleJpaRepository`. Previously emitted `cb.conjunction()` (`WHERE 1=1`), which breaks composition via `.and(otherSpec)` and blocks some query-planner optimizations. New unit tests pin the null/non-null contract for AlwaysAllowed/AlwaysDenied. - **H1**: Detect `eq(var, var)` explicitly and throw "Field-to-field comparison is not supported for operator 'eq': X vs Y" instead of the misleading "Missing value operand for eq". - **H3**: When a `map(...)` expression appears as a leaf comparison operand (e.g. `eq(map(...), [...])`), throw with a hint pointing users at `hasIntersection(map(...), [...])` — matches Prisma's recently-fixed message in #235. - **H4**: Standardise unsupported-operator wording to "Unsupported operator:" (was "Unknown operator:") to align with Prisma. ES-Java and SQLAlchemy should follow in separate PRs. - **H2**: Throw-tests now assert message substrings (operator name + hint where relevant) via new `assertActionThrows` / `assertConditionThrows` helpers. Stops silent regressions to less-helpful messages or different exception types. Also updates the `ternary` test op name from "conditional" to "if" — the CEL planner emits `if(cond, then, else)` in the AST. Tests: 161/161 pass (gradle test --rerun-tasks, JDK 17, Testcontainers PDP). Signed-off-by: Alex Olivier --- spring-data/build.gradle.kts | 11 +- .../cerbos/queryplan/springdata/Result.java | 17 +- .../SpringDataQueryPlanAdapter.java | 28 ++- .../springdata/SpringDataIntegrationTest.java | 54 +++-- .../SpringDataQueryPlanAdapterTest.java | 190 ++++++++++++------ 5 files changed, 212 insertions(+), 88 deletions(-) diff --git a/spring-data/build.gradle.kts b/spring-data/build.gradle.kts index 5cde9e15..8d19f56d 100644 --- a/spring-data/build.gradle.kts +++ b/spring-data/build.gradle.kts @@ -17,9 +17,16 @@ repositories { dependencies { implementation("dev.cerbos:cerbos-sdk-java:0.18.0") implementation("com.google.protobuf:protobuf-java:4.31.1") - implementation("org.springframework.data:spring-data-jpa:3.5.1") - implementation("jakarta.persistence:jakarta.persistence-api:3.2.0") + // Spring Data JPA + Jakarta Persistence are provided by the consuming application's + // Spring Boot BOM (or equivalent). Declaring them as `compileOnly` keeps them out of + // the published POM as transitive dependencies so they don't pin a specific version on + // downstream consumers — matching how Spring Data JPA itself marks `hibernate-core` + // as `true`. + compileOnly("org.springframework.data:spring-data-jpa:3.5.1") + compileOnly("jakarta.persistence:jakarta.persistence-api:3.2.0") + testImplementation("org.springframework.data:spring-data-jpa:3.5.1") + testImplementation("jakarta.persistence:jakarta.persistence-api:3.2.0") testImplementation(platform("org.junit:junit-bom:5.12.2")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.testcontainers:testcontainers:1.21.3") diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java index fc6d3739..27eeb75b 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java @@ -9,9 +9,14 @@ public sealed interface Result permits Result.AlwaysAllowed, Result.AlwaysDen * caller's own Specifications via {@code .and(...)} / {@code .or(...)}: * *
    - *
  • {@link AlwaysAllowed} – always-true predicate ({@code 1=1})
  • - *
  • {@link AlwaysDenied} – always-false predicate ({@code 1=0})
  • - *
  • {@link Conditional} – the wrapped Specification
  • + *
  • {@link AlwaysAllowed} – {@code null} predicate; Spring Data's + * {@code SimpleJpaRepository} treats this as "no restriction" and omits the + * {@code WHERE} clause entirely (matches {@link Specification#unrestricted()}).
  • + *
  • {@link AlwaysDenied} – always-false predicate ({@code 1=0}).
  • + *
  • {@link Conditional} – the wrapped Specification. The lambda is invoked fresh + * for every query (including Spring Data's separate COUNT pass under + * {@code findAll(spec, Pageable)}), so callers must not cache the produced + * {@code Predicate} across query executions.
  • *
*/ Specification toSpecification(); @@ -19,7 +24,11 @@ public sealed interface Result permits Result.AlwaysAllowed, Result.AlwaysDen record AlwaysAllowed() implements Result { @Override public Specification toSpecification() { - return (root, query, cb) -> cb.conjunction(); + // Returning null is the canonical "no restriction" signal — Spring Data's + // SimpleJpaRepository.applySpecificationToCriteria guards with + // `if (predicate != null) query.where(predicate)`, so this avoids emitting + // `WHERE 1=1` and keeps composition with `.and(otherSpec)` clean. + return (root, query, cb) -> null; } } diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 9b95e244..62b15c58 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -174,11 +174,35 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc boolean valueSeen = false; for (Operand o : operands) { switch (o.getNodeCase()) { - case VARIABLE -> variable = o.getVariable(); + case VARIABLE -> { + if (variable != null) { + // H1: field-to-field comparison is not expressible in JPA Criteria as a + // value-bound predicate. Surface this explicitly rather than the generic + // "Missing value operand" message the loop would otherwise produce. + throw new IllegalArgumentException( + "Field-to-field comparison is not supported for operator '" + + op + "': " + variable + " vs " + o.getVariable()); + } + variable = o.getVariable(); + } case VALUE -> { value = protoValueToJava(o.getValue()); valueSeen = true; } + case EXPRESSION -> { + // H3: map() compositions are only accepted inside hasIntersection. + // A direct comparison like eq(map(...), [...]) reaches here; point users + // at the supported shape rather than throwing a generic operand error. + String innerOp = o.getExpression().getOperator(); + if ("map".equals(innerOp)) { + throw new IllegalArgumentException( + "Direct comparison of map(...) to a value is not supported " + + "(operator: " + op + "). Wrap the map() expression in " + + "hasIntersection(map(...), [...]) instead."); + } + throw new IllegalArgumentException( + "Unexpected " + innerOp + "() expression in leaf operand of " + op); + } default -> throw new IllegalArgumentException( "Unexpected operand type in leaf expression: " + o.getNodeCase()); } @@ -222,7 +246,7 @@ private Predicate defaultLeaf(String op, Path path, Object value) { case "contains" -> cb.like(path.as(String.class), "%" + escapeLike(String.valueOf(value)) + "%", '\\'); case "startsWith" -> cb.like(path.as(String.class), escapeLike(String.valueOf(value)) + "%", '\\'); case "endsWith" -> cb.like(path.as(String.class), "%" + escapeLike(String.valueOf(value)), '\\'); - default -> throw new IllegalArgumentException("Unknown operator: " + op); + default -> throw new IllegalArgumentException("Unsupported operator: " + op); }; } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index a619b42a..2f0c01d8 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -44,6 +44,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * End-to-end test against a real Cerbos PDP. @@ -373,6 +374,22 @@ private static List runNested(String action) { return runWithMapping(action, NESTED_FIELD_MAP); } + /** + * Assert that translating {@code action} throws an {@link IllegalArgumentException} whose + * message contains every one of {@code messageFragments}. Pins the error contract so a future + * refactor can't silently regress to a less-helpful message (or to a different exception type). + */ + private static void assertActionThrows(String action, + Map mapping, + String... messageFragments) { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> runWithMapping(action, mapping)); + for (String fragment : messageFragments) { + assertTrue(ex.getMessage().contains(fragment), + "expected message to contain '" + fragment + "' but was: " + ex.getMessage()); + } + } + // -- always allow/deny -- @Test @@ -957,63 +974,65 @@ void emptyCollection() { @Test void arithAddThrows() { - assertThrows(IllegalArgumentException.class, () -> run("arith-add")); + // The planner emits gt(add(field, 1.0), 2.0); handleAddComparison rejects non-eq/ne. + assertActionThrows("arith-add", FIELD_MAP, "add", "gt"); } @Test void arithSubThrows() { - assertThrows(IllegalArgumentException.class, () -> run("arith-sub")); + assertActionThrows("arith-sub", FIELD_MAP, "sub"); } @Test void arithMultThrows() { - assertThrows(IllegalArgumentException.class, () -> run("arith-mult")); + assertActionThrows("arith-mult", FIELD_MAP, "mult"); } @Test void arithDivThrows() { - assertThrows(IllegalArgumentException.class, () -> run("arith-div")); + assertActionThrows("arith-div", FIELD_MAP, "div"); } @Test void arithModThrows() { - assertThrows(IllegalArgumentException.class, () -> run("arith-mod")); + assertActionThrows("arith-mod", FIELD_MAP, "mod"); } @Test void matchesRegexThrows() { - assertThrows(IllegalArgumentException.class, () -> run("matches-regex")); + assertActionThrows("matches-regex", FIELD_MAP, "Unsupported operator", "matches"); } @Test void indexListThrows() { - assertThrows(IllegalArgumentException.class, () -> run("index-list")); + assertActionThrows("index-list", FIELD_MAP, "index"); } @Test void convertStringThrows() { - assertThrows(IllegalArgumentException.class, () -> run("convert-string")); + assertActionThrows("convert-string", FIELD_MAP, "string"); } @Test void convertDoubleThrows() { - assertThrows(IllegalArgumentException.class, () -> run("convert-double")); + assertActionThrows("convert-double", FIELD_MAP, "double"); } @Test void convertIntThrows() { - assertThrows(IllegalArgumentException.class, () -> run("convert-int")); + assertActionThrows("convert-int", FIELD_MAP, "int"); } @Test void ternaryThrows() { - assertThrows(IllegalArgumentException.class, () -> run("ternary")); + // The CEL planner emits ternary as `if(cond, then, else)` — not `conditional`. + assertActionThrows("ternary", FIELD_MAP, "if()"); } @Test void stringSizeThrows() { // size(R.attr.aString) > 0 — adapter only handles size() on Relation mappings. - assertThrows(IllegalArgumentException.class, () -> run("string-size")); + assertActionThrows("string-size", FIELD_MAP, "size()", "Relation"); } } @@ -1030,8 +1049,8 @@ void isNotSet() { @Test void equalFieldToFieldThrows() { - // aString == id — adapter requires exactly one value operand for eq. - assertThrows(IllegalArgumentException.class, () -> run("equal-field-to-field")); + // aString == id — adapter rejects two-variable comparisons with a specific message. + assertActionThrows("equal-field-to-field", FIELD_MAP, "Field-to-field", "eq"); } @Test @@ -1071,17 +1090,18 @@ void allWithNestedAnd() { } // TODO(#232): the adapter's handleHasIntersection is the only path that accepts a map() - // expression. A bare `eq(map(...), [...])` is rejected by the leaf operator handler. + // expression. A bare `eq(map(...), [...])` is rejected with a hint at the supported shape. @Test void mapComparedToLiteralListThrows() { - assertThrows(IllegalArgumentException.class, () -> runNested("map-compared")); + assertActionThrows("map-compared", NESTED_FIELD_MAP, + "map(...)", "hasIntersection"); } // TODO(#232): trySizeComparison only accepts a Variable as size()'s operand, so // `size(filter(...)) > 0` falls through and throws. @Test void sizeOfFilterThrows() { - assertThrows(IllegalArgumentException.class, () -> runNested("filter-count-gt")); + assertActionThrows("filter-count-gt", NESTED_FIELD_MAP, "size()"); } } } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index 24ff2977..bcf3812b 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -27,6 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -112,6 +114,21 @@ private static Operand lambda(String varName, Operand body) { return exprOp("lambda", body, var(varName)); } + /** + * Assert that {@link #runCount} for {@code condition} throws {@link IllegalArgumentException} + * whose message contains every {@code messageFragments} entry. Pins error contracts so a + * future refactor can't silently regress to a less-helpful message or different exception + * type. + */ + private static void assertConditionThrows(Operand condition, String... messageFragments) { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> runCount(condition)); + for (String fragment : messageFragments) { + assertTrue(ex.getMessage().contains(fragment), + "expected message to contain '" + fragment + "' but was: " + ex.getMessage()); + } + } + /** * Build a Specification, translate to a predicate, and run the query — returns the row count. * Exercises the full path so any IllegalArgumentException during predicate building surfaces. @@ -146,6 +163,23 @@ void alwaysAllowedResult() { assertInstanceOf(Result.AlwaysAllowed.class, result); } + @Test + void alwaysAllowedSpecificationReturnsNullPredicate() { + // Contract: AlwaysAllowed.toSpecification() must produce a Specification whose + // toPredicate returns null — Spring Data's SimpleJpaRepository skips the WHERE + // clause entirely in that case. Pins B2 against regression to cb.conjunction(). + Specification spec = new Result.AlwaysAllowed().toSpecification(); + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(ResourceEntity.class); + Root root = cq.from(ResourceEntity.class); + assertNull(spec.toPredicate(root, cq, cb)); + } finally { + em.close(); + } + } + @Test void alwaysDeniedResult() { PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_ALWAYS_DENIED, null); @@ -154,6 +188,22 @@ void alwaysDeniedResult() { assertInstanceOf(Result.AlwaysDenied.class, result); } + @Test + void alwaysDeniedSpecificationReturnsDisjunction() { + // Symmetric pin: AlwaysDenied.toSpecification() must produce a non-null predicate. + Specification spec = new Result.AlwaysDenied().toSpecification(); + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(ResourceEntity.class); + Root root = cq.from(ResourceEntity.class); + assertNotNull(spec.toPredicate(root, cq, cb), + "AlwaysDenied must emit an explicit predicate, not null"); + } finally { + em.close(); + } + } + @Test void eqOnString() { assertEquals(0, runCount(exprOp("eq", var("request.resource.attr.aString"), sval("foo")))); @@ -276,10 +326,12 @@ void sizeEqZeroBuildsNotExists() { @Test void unsupportedSizeComparisonThrows() { - Operand cond = exprOp("gt", - exprOp("size", var("request.resource.attr.ownedBy")), - nval(5)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + // Only emptiness checks (size > 0, size == 0) are supported; size > 5 must throw. + assertConditionThrows( + exprOp("gt", + exprOp("size", var("request.resource.attr.ownedBy")), + nval(5)), + "size", "Unsupported size comparison"); } @Test @@ -338,7 +390,7 @@ void unknownOperatorThrows() { var("request.resource.attr.aString"), sval("v")); IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> runCount(cond)); - assertTrue(ex.getMessage().contains("Unknown operator")); + assertTrue(ex.getMessage().contains("Unsupported operator")); } // -- add operator -- @@ -456,102 +508,113 @@ void emptyCollectionBuildsNotExists() { @Test void arithAddInComparisonThrows() { - // gt(add(field, 1.0), 2.0) — adapter only folds add() for eq/ne with field refs. - Operand cond = exprOp("gt", - exprOp("add", var("request.resource.attr.aNumber"), nval(1)), - nval(2)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + // gt(add(field, 1.0), 2.0) — handleAddComparison rejects non-eq/ne ops. + assertConditionThrows( + exprOp("gt", + exprOp("add", var("request.resource.attr.aNumber"), nval(1)), + nval(2)), + "add", "gt"); } @Test void arithSubThrows() { - Operand cond = exprOp("lt", - exprOp("sub", var("request.resource.attr.aNumber"), nval(1)), - nval(2)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("lt", + exprOp("sub", var("request.resource.attr.aNumber"), nval(1)), + nval(2)), + "sub"); } @Test void arithMultThrows() { - Operand cond = exprOp("gt", - exprOp("mult", var("request.resource.attr.aNumber"), nval(2)), - nval(2)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("gt", + exprOp("mult", var("request.resource.attr.aNumber"), nval(2)), + nval(2)), + "mult"); } @Test void arithDivThrows() { - Operand cond = exprOp("gt", - exprOp("div", var("request.resource.attr.aNumber"), nval(2)), - nval(0)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("gt", + exprOp("div", var("request.resource.attr.aNumber"), nval(2)), + nval(0)), + "div"); } @Test void arithModThrows() { - Operand cond = exprOp("eq", - exprOp("mod", var("request.resource.attr.aNumber"), nval(2)), - nval(0)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("eq", + exprOp("mod", var("request.resource.attr.aNumber"), nval(2)), + nval(0)), + "mod"); } @Test void matchesRegexThrows() { - Operand cond = exprOp("matches", - var("request.resource.attr.aString"), sval("^str.*")); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("matches", + var("request.resource.attr.aString"), sval("^str.*")), + "Unsupported operator", "matches"); } @Test void indexListThrows() { // ownedBy[0] == "user1" — array indexing not supported. - Operand cond = exprOp("eq", - exprOp("index", var("request.resource.attr.ownedBy"), nval(0)), - sval("user1")); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("eq", + exprOp("index", var("request.resource.attr.ownedBy"), nval(0)), + sval("user1")), + "index"); } @Test void convertStringThrows() { - Operand cond = exprOp("eq", - exprOp("string", var("request.resource.attr.aNumber")), - sval("1")); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("eq", + exprOp("string", var("request.resource.attr.aNumber")), + sval("1")), + "string"); } @Test void convertDoubleThrows() { - Operand cond = exprOp("gt", - exprOp("double", var("request.resource.attr.aNumber")), - nval(1.5)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("gt", + exprOp("double", var("request.resource.attr.aNumber")), + nval(1.5)), + "double"); } @Test void convertIntThrows() { - Operand cond = exprOp("gt", - exprOp("int", var("request.resource.attr.aString")), - nval(0)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("gt", + exprOp("int", var("request.resource.attr.aString")), + nval(0)), + "int"); } @Test void ternaryThrows() { - Operand ternary = exprOp("conditional", + // The CEL planner emits ternary as `if(cond, then, else)` in the AST. + Operand ternary = exprOp("if", var("request.resource.attr.aBool"), var("request.resource.attr.aNumber"), nval(0)); - Operand cond = exprOp("gt", ternary, nval(0)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows(exprOp("gt", ternary, nval(0)), "if()"); } @Test void stringSizeThrows() { // size(aString) > 0 — size() requires a Relation mapping; aString is a Field. - Operand cond = exprOp("gt", - exprOp("size", var("request.resource.attr.aString")), - nval(0)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("gt", + exprOp("size", var("request.resource.attr.aString")), + nval(0)), + "size()", "Relation"); } } @@ -569,11 +632,12 @@ void isNotSetBuildsIsNull() { @Test void equalFieldToFieldThrows() { - // eq(var, var) — adapter requires exactly one value operand. - Operand cond = exprOp("eq", - var("request.resource.attr.aString"), - var("request.resource.attr.createdBy")); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + // eq(var, var) — adapter rejects two-variable comparisons with a specific message. + assertConditionThrows( + exprOp("eq", + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy")), + "Field-to-field", "eq"); } @Test @@ -622,8 +686,9 @@ void mapComparedToLiteralListThrows() { Operand mapExpr = exprOp("map", var("request.resource.attr.tags"), lambda("t", var("t.id"))); - Operand cond = exprOp("eq", mapExpr, listOp("tag1", "tag2")); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("eq", mapExpr, listOp("tag1", "tag2")), + "map(...)", "hasIntersection"); } @Test @@ -632,10 +697,9 @@ void sizeOfFilterThrows() { Operand filterExpr = exprOp("filter", var("request.resource.attr.tags"), lambda("t", exprOp("eq", var("t.name"), sval("public")))); - Operand cond = exprOp("gt", - exprOp("size", filterExpr), - nval(0)); - assertThrows(IllegalArgumentException.class, () -> runCount(cond)); + assertConditionThrows( + exprOp("gt", exprOp("size", filterExpr), nval(0)), + "size()"); } } From 66dca724fd5112251b61d86bf9d9ed67e5911c8c Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 18 May 2026 21:41:20 +0200 Subject: [PATCH 04/20] docs(spring-data): land review nice-to-haves (H5, N1, N3-N5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **N1** (README): "Not yet supported" table enumerating the gaps surfaced by this PR's test suite — arithmetic, regex, list indexing, casts, ternary (`if`), `size(string)`, field-to-field eq, `eq(map(...), [...])`, `size(filter(...))`, non-emptiness `size(coll) > N`, hierarchy ops. Names the override path (`OperatorFunction`) for each so consumers know how to unblock themselves per dialect. - **H5** (Result.Conditional Javadoc): warn that the wrapped Specification is re-invoked per query (Spring Data's separate COUNT pass under `findAll(spec, Pageable)` uses a different Root); callers must never cache the produced Predicate. Without this Hibernate 6 throws `SqlTreeCreationException: Could not locate TableGroup`. - **N3** (chain sentinel): swap `__chain__` → `$$chain$$` for the internal lambda variable name in `chainedExistsSubquery`. `$` is not a valid CEL identifier character, so the sentinel can never collide with a user-supplied lambda name even under a future refactor that calls `resolvePath` on the intermediate scope. - **N4** (`protoValueToJava`): handle `KIND_NOT_SET` explicitly with an actionable message ("Protobuf Value has no kind set — the planner emitted a malformed operand") instead of falling through to the generic default. - **N5** (`foldAdd`): pre-check both operands for `null` so the error path emits "add requires non-null operands" instead of NPE'ing on `.getClass()`. Tests: 161/161 still pass. Signed-off-by: Alex Olivier --- spring-data/README.md | 21 +++++++++++++++++++ .../cerbos/queryplan/springdata/Result.java | 12 +++++++++++ .../SpringDataQueryPlanAdapter.java | 13 +++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/spring-data/README.md b/spring-data/README.md index 2a3b6a44..e42cca86 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -135,6 +135,27 @@ Result result = SpringDataQueryPlanAdapter.toSpecification(planResult, MAPPING, overrides); ``` +## Not yet supported + +The Criteria-based predicate builder has no shape for these CEL constructs; they +throw `IllegalArgumentException` with a message naming the operator. Override +via `OperatorFunction` when the runtime can express them (e.g. database-specific +SQL fragments), or wait for adapter support. + +| Construct | Example CEL | Notes | +|-------------------------------------------------|---------------------------------------------------|-------| +| Arithmetic (`add`/`sub`/`mult`/`div`/`mod`) | `R.attr.aNumber + 1 > 2` | `add` is supported only as constant folding inside `eq`/`ne`; other arithmetic on document fields requires a column-expression engine the Criteria API doesn't expose. | +| Regex match | `R.attr.aString.matches("^foo.*")` | JPA has no portable regex predicate; override per-dialect (`regexp_like`, `~`, `REGEXP`). | +| List indexing | `R.attr.tags[0] == "x"` | JPA collections are unordered sets — no positional access. | +| Type casts (`int(...)` / `double(...)` / `string(...)`) | `int(R.attr.aString) > 0` | No portable `CAST` in Criteria; override per-dialect. | +| Ternary (`cond ? a : b`) | `(R.attr.aBool ? R.attr.aNumber : 0) > 0` | The CEL planner emits this as `if(cond, then, else)`; JPA Criteria has no `CASE WHEN` value-expression builder. | +| `size(string)` | `size(R.attr.aString) > 0` | Only `size(collection)` (`Relation` mapping) is supported; for strings use `cb.length` via an override. | +| Field-to-field comparison | `R.attr.aString == R.attr.id` | The leaf operator handler requires one variable + one value operand; throws explicitly. | +| `eq(map(...), [...])` | `R.attr.tags.map(t, t.id) == ["tag1", "tag2"]` | Use `hasIntersection(map(...), [...])` instead. | +| `size(filter(...)) N` | `size(R.attr.tags.filter(t, t.name == "x")) > 0` | Use `exists(coll, lambda)` for emptiness; `size()` only accepts a Variable operand. | +| `size(coll) N` for `N > 0` | `size(R.attr.tags) > 5` | Only emptiness checks are supported. | +| Hierarchy operators (`hierarchy-*`) | `hierarchy.overlaps(...)` | Not yet ported from the Prisma adapter; ~250 LoC follow-up. | + ## Build From the `spring-data/` directory: diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java index 27eeb75b..983f16a8 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java @@ -39,6 +39,18 @@ public Specification toSpecification() { } } + /** + * Wraps the translated Specification. The contained Specification is a fresh lambda that + * rebuilds the entire predicate tree from the {@code Root}/{@code CriteriaQuery} passed in + * on each invocation — this is required because Spring Data's + * {@code JpaSpecificationExecutor.findAll(spec, Pageable)} fires a separate {@code COUNT} + * query with its own {@code CriteriaQuery} and {@code Root}, and Hibernate 6 rejects a + * cached {@code Predicate} produced against a different {@code Root} + * ({@code SqlTreeCreationException: Could not locate TableGroup}). Callers must therefore + * never cache or re-use the {@code Predicate} returned by + * {@link Specification#toPredicate}; pass the Specification itself to repository methods + * and let Spring Data invoke it once per query. + */ record Conditional(Specification specification) implements Result { @Override public Specification toSpecification() { diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 62b15c58..2ce3a423 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -622,8 +622,10 @@ private Predicate chainedExistsSubquery(Scope scope, } return existsSubquery(scope, chain.get(0), (sub, joinFrom) -> { // Recurse using an intermediate scope rooted at the current join + this subquery. + // The lambda variable name is internal-only — `$` is not a valid CEL identifier + // character, so this sentinel can never collide with a user-supplied lambda name. AttributeMapping.Relation thisRel = chain.get(0); - Scope intermediate = Scope.lambda(joinFrom, sub, thisRel, "__chain__"); + Scope intermediate = Scope.lambda(joinFrom, sub, thisRel, "$$chain$$"); return chainedExistsSubquery(intermediate, chain.subList(1, chain.size()), bodyBuilder); }); } @@ -750,6 +752,13 @@ public AttributeMapping resolveMapping(String cerbosVar) { * numbers add. Used when the planner emits e.g. {@code eq(field, add("prefix:", "123"))}. */ static Object foldAdd(Object left, Object right) { + if (left == null || right == null) { + // Reaching here means the planner emitted `add(null, ...)` or `add(..., null)` + // — neither side could satisfy any string/number equation, so report the shape + // explicitly rather than NPE'ing on `.getClass()` below. + throw new IllegalArgumentException( + "add requires non-null operands, got " + left + " + " + right); + } if (left instanceof String || right instanceof String) { return String.valueOf(left) + String.valueOf(right); } @@ -913,6 +922,8 @@ static Object protoValueToJava(Value value) { .toList(); case STRUCT_VALUE -> value.getStructValue().getFieldsMap().entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, e -> protoValueToJava(e.getValue()))); + case KIND_NOT_SET -> throw new IllegalArgumentException( + "Protobuf Value has no kind set — the planner emitted a malformed operand"); default -> throw new IllegalArgumentException( "Unsupported protobuf value type: " + value.getKindCase()); }; From f86f5eb5a8faf99c8ce22e72334cf401e7b784d8 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 18 May 2026 22:04:45 +0200 Subject: [PATCH 05/20] add example Signed-off-by: Alex Olivier --- spring-data/README.md | 61 ++++++++++ spring-data/example/.gitignore | 5 + spring-data/example/README.md | 107 ++++++++++++++++++ spring-data/example/build.gradle.kts | 34 ++++++ spring-data/example/cerbos-config.yaml | 26 +++++ spring-data/example/docker-compose.yml | 28 +++++ spring-data/example/policies/photo.yaml | 53 +++++++++ spring-data/example/scripts/smoke.sh | 88 ++++++++++++++ spring-data/example/settings.gradle.kts | 7 ++ .../example/photos/CerbosClientConfig.java | 20 ++++ .../java/dev/cerbos/example/photos/Photo.java | 65 +++++++++++ .../example/photos/PhotoController.java | 36 ++++++ .../example/photos/PhotoRepository.java | 8 ++ .../cerbos/example/photos/PhotoService.java | 49 ++++++++ .../example/photos/PhotosApplication.java | 11 ++ .../dev/cerbos/example/photos/SeedData.java | 34 ++++++ .../src/main/resources/application.yaml | 23 ++++ 17 files changed, 655 insertions(+) create mode 100644 spring-data/example/.gitignore create mode 100644 spring-data/example/README.md create mode 100644 spring-data/example/build.gradle.kts create mode 100644 spring-data/example/cerbos-config.yaml create mode 100644 spring-data/example/docker-compose.yml create mode 100644 spring-data/example/policies/photo.yaml create mode 100755 spring-data/example/scripts/smoke.sh create mode 100644 spring-data/example/settings.gradle.kts create mode 100644 spring-data/example/src/main/java/dev/cerbos/example/photos/CerbosClientConfig.java create mode 100644 spring-data/example/src/main/java/dev/cerbos/example/photos/Photo.java create mode 100644 spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoController.java create mode 100644 spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoRepository.java create mode 100644 spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoService.java create mode 100644 spring-data/example/src/main/java/dev/cerbos/example/photos/PhotosApplication.java create mode 100644 spring-data/example/src/main/java/dev/cerbos/example/photos/SeedData.java create mode 100644 spring-data/example/src/main/resources/application.yaml diff --git a/spring-data/README.md b/spring-data/README.md index e42cca86..03bb8b8b 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -156,6 +156,67 @@ SQL fragments), or wait for adapter support. | `size(coll) N` for `N > 0` | `size(R.attr.tags) > 5` | Only emptiness checks are supported. | | Hierarchy operators (`hierarchy-*`) | `hierarchy.overlaps(...)` | Not yet ported from the Prisma adapter; ~250 LoC follow-up. | +## Gotchas + +Things you're likely to hit when integrating the adapter into a Spring Boot app — see the +[`example/`](example) photo-sharing application for a runnable end-to-end reference. + +### Pin `protobuf-java` to the cerbos-sdk-java's gencode version + +`cerbos-sdk-java` 0.18.0 ships protobuf message classes generated against +`protobuf-java` 4.33.5. If your application classpath ends up with an **older** runtime +— either because you pin it explicitly, or a transitive dependency wins resolution — the +SDK throws on first message decode: + +```text +com.google.protobuf.RuntimeVersion$ProtobufRuntimeVersionException: + Detected incompatible Protobuf Gencode/Runtime versions when loading Principal: + gencode 4.33.5, runtime 4.31.1. Runtime version cannot be older than the linked gencode version. +``` + +Fix — add a direct dependency matching the SDK's gencode: + +```kotlin +implementation("com.google.protobuf:protobuf-java:4.33.5") +``` + +Spring Boot's BOM does not manage `protobuf-java`, so without an explicit pin Gradle's +default conflict resolver picks the highest version on the graph. Pinning makes the +contract explicit and survives BOM upgrades. + +### `@ElementCollection` / `@OneToMany` + `spring.jpa.open-in-view=false` + +Mapping a Cerbos attribute via `AttributeMapping.relation(...)` translates `"x" in tags` +to a correlated `EXISTS` subquery — but the entity collection itself is still lazy by +default. If your controller serializes the entity (or any field traversal happens after +the transaction closes), you'll see: + +```text +HttpMessageNotWritableException: Could not write JSON: + failed to lazily initialize a collection of role: …Photo.tags: could not initialize proxy - no Session +``` + +Pick one: + +- **Eager-fetch** the collection if it's small (`@ElementCollection(fetch = FetchType.EAGER)`). +- **Do the entity-to-DTO mapping inside `@Transactional(readOnly = true)`** so the Hibernate + session is still open while you walk relations. +- **Don't serialize entities** — return a DTO projection instead. + +The adapter itself has no opinion here — this is the same `open-in-view=false` footgun any +JPA app hits — but it's worth flagging because Cerbos plans frequently *do* reference +collection attributes (`tags`, `members`, `categories`), and those are the ones developers +typically forget to fetch. + +### Don't cache the produced `Predicate` + +`Result.Conditional.toSpecification()` returns a Specification whose lambda **rebuilds the +predicate tree against each invocation's `Root`/`CriteriaQuery`**. Spring Data's +`findAll(spec, Pageable)` fires a separate `COUNT` query with its own root, and Hibernate 6 +rejects a `Predicate` produced against a stale root with +`SqlTreeCreationException: Could not locate TableGroup`. Pass the Specification to +repository methods; don't cache the `Predicate` it returns. + ## Build From the `spring-data/` directory: diff --git a/spring-data/example/.gitignore b/spring-data/example/.gitignore new file mode 100644 index 00000000..62a4eb55 --- /dev/null +++ b/spring-data/example/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +bin/ +*.iml +.idea/ diff --git a/spring-data/example/README.md b/spring-data/example/README.md new file mode 100644 index 00000000..a06bc6c7 --- /dev/null +++ b/spring-data/example/README.md @@ -0,0 +1,107 @@ +# cerbos-spring-data — photo-sharing example + +A minimal Spring Boot + JPA application that uses the [`cerbos-spring-data`](..) adapter +to filter a `photos` table according to a Cerbos `PlanResources` decision served by a real +PDP container. + +## What it does + +1. Spring Boot exposes `GET /photos?user=&role=&action=`. +2. The controller calls the Cerbos PDP for a query plan over the `photo` resource. +3. The adapter turns the plan into a JPA `Specification`. +4. `PhotoRepository.findAll(spec)` runs the SQL. + +No filtering is hand-rolled in Java — the predicates come straight from the policy. + +## Layout + +``` +example/ +├── policies/photo.yaml # resource policy (view/edit/delete/comment) +├── cerbos-config.yaml # PDP config (audit logs to stdout) +├── docker-compose.yml # spins up ghcr.io/cerbos/cerbos:latest +├── settings.gradle.kts # composite-build include of ../ (the adapter) +├── build.gradle.kts # Spring Boot 3.5 + JPA + H2 + adapter +├── scripts/smoke.sh # end-to-end script (compose up → bootRun → curl asserts) +└── src/main/ + ├── resources/application.yaml + └── java/dev/cerbos/example/photos/ + ├── PhotosApplication.java + ├── Photo.java @Entity (id, ownerId, isPublic, isArchived, tags…) + ├── PhotoRepository.java JpaRepository + JpaSpecificationExecutor + ├── PhotoService.java builds plan, calls adapter, runs the spec + ├── PhotoController.java REST surface + ├── CerbosClientConfig.java @Bean CerbosBlockingClient + └── SeedData.java loads 6 photos at boot +``` + +## Policy at a glance + +| Action | Rule (role `user`) | +|---------|-----------------------------------------------------------------------------| +| view | `(public && !archived) || ownerId == self` | +| edit | `ownerId == self` | +| delete | `ownerId == self` | +| comment | `(public && !archived) || "friends" in tags || ownerId == self` | + +Role `admin` always allowed. See [`policies/photo.yaml`](policies/photo.yaml). + +## Run it + +```bash +# 1. start the Cerbos PDP (mounts ./policies into the container) +docker compose up -d + +# 2. start the Spring Boot app +gradle bootRun --no-daemon +# … or with the wrapper from ../, if you have one + +# 3. in another shell — hit the API +curl -s "http://localhost:8080/photos?user=alice&action=view" | jq '[.[].id]' +# => ["p1","p2","p5","p6"] +``` + +Or one-shot via the smoke test: + +```bash +./scripts/smoke.sh +``` + +## End-to-end smoke run + +`scripts/smoke.sh` brings up the PDP, runs `gradle bootRun` in the background, and asserts +the response IDs for eight `(user, role, action)` permutations. On success it prints the +last 20 lines of the PDP audit log so you can see actual `PlanResources` calls flowing +into the container — proving the result came from a live policy decision, not a stub. + +## Adapter wiring + +```java +private static final Map PHOTO_ATTRS = Map.of( + "request.resource.attr.ownerId", AttributeMapping.field("ownerId"), + "request.resource.attr.public", AttributeMapping.field("isPublic"), + "request.resource.attr.archived", AttributeMapping.field("isArchived"), + "request.resource.attr.tags", AttributeMapping.relation("tags") +); + +PlanResourcesResult plan = cerbos.plan( + Principal.newInstance(userId).withRoles(role), + Resource.newInstance("photo"), + action); + +Result result = SpringDataQueryPlanAdapter.toSpecification(plan, PHOTO_ATTRS); +return repository.findAll(result.toSpecification()); +``` + +`AttributeMapping.relation("tags")` is what makes `"friends" in tags` translate to a +correlated `EXISTS` subquery against the `photo_tags` join table. + +## What this proves + +- The adapter compiles against a real Spring Boot 3.5 application without dragging in + conflicting Spring/JPA versions (its Spring deps are `compileOnly`). +- The PDP — not the app — decides which photos are visible. Changing + `policies/photo.yaml` and re-running the smoke script flips the result set without a + single line of Java change. +- `KIND_ALWAYS_ALLOWED` (admin) and `KIND_CONDITIONAL` (user) both round-trip through + the adapter and land as a usable `Specification`. diff --git a/spring-data/example/build.gradle.kts b/spring-data/example/build.gradle.kts new file mode 100644 index 00000000..d8f4b9fc --- /dev/null +++ b/spring-data/example/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + java + id("org.springframework.boot") version "3.5.1" + id("io.spring.dependency-management") version "1.1.6" +} + +group = "dev.cerbos.example" +version = "0.0.1" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + runtimeOnly("com.h2database:h2") + + // Pulled in via the composite-build include in settings.gradle.kts — points at ../ + implementation("dev.cerbos:cerbos-spring-data:0.1.0-alpha.1") + implementation("dev.cerbos:cerbos-sdk-java:0.18.0") + // Match the protobuf-java gencode the SDK was generated against; older versions throw + // RuntimeVersion$ProtobufRuntimeVersionException at first message decode. + implementation("com.google.protobuf:protobuf-java:4.33.5") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +tasks.test { useJUnitPlatform() } diff --git a/spring-data/example/cerbos-config.yaml b/spring-data/example/cerbos-config.yaml new file mode 100644 index 00000000..34bb0ee1 --- /dev/null +++ b/spring-data/example/cerbos-config.yaml @@ -0,0 +1,26 @@ +server: + httpListenAddr: ":3592" + grpcListenAddr: ":3593" + +storage: + driver: "disk" + disk: + directory: /policies + watchForChanges: true + +telemetry: + disabled: true + +schema: + enforcement: reject + +# Stream decision/audit logs to stdout — `docker compose logs cerbos` then shows every +# PlanResources call the Spring Boot app made, so you can see what filter the adapter +# turned each plan into. +audit: + enabled: true + accessLogsEnabled: true + decisionLogsEnabled: true + backend: file + file: + path: stdout diff --git a/spring-data/example/docker-compose.yml b/spring-data/example/docker-compose.yml new file mode 100644 index 00000000..2868060e --- /dev/null +++ b/spring-data/example/docker-compose.yml @@ -0,0 +1,28 @@ +# Standalone Cerbos PDP for the photo-sharing example app. +# +# docker compose up -d # start the PDP +# ../gradlew bootRun # run the Spring Boot app on :8080 (connects to :3593) +# docker compose logs cerbos # see PlanResources decision logs +# docker compose down +services: + cerbos: + image: ghcr.io/cerbos/cerbos:latest + container_name: cerbos-photos-example + command: + - "server" + - "--config=/config/cerbos-config.yaml" + environment: + CERBOS_NO_TELEMETRY: "1" + CERBOS_CONFIG: "/config/cerbos-config.yaml" + ports: + - "3592:3592" + - "3593:3593" + volumes: + - ./cerbos-config.yaml:/config/cerbos-config.yaml:ro + - ./policies:/policies:ro + healthcheck: + test: ["CMD", "/cerbos", "healthcheck"] + interval: 2s + timeout: 5s + retries: 30 + start_period: 3s diff --git a/spring-data/example/policies/photo.yaml b/spring-data/example/policies/photo.yaml new file mode 100644 index 00000000..b8504076 --- /dev/null +++ b/spring-data/example/policies/photo.yaml @@ -0,0 +1,53 @@ +# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json +# +# Photo-sharing example policy. The cerbos-spring-data adapter translates +# the PDP's query-plan response into a JPA Specification at runtime, +# so each rule below corresponds to one or more JPA Criteria predicates. +# +# view : public + not archived OR ownerId == self +# edit : ownerId == self +# delete : ownerId == self +# comment : public + not archived, OR "friends" tag set, OR ownerId == self +# admin : unconditional ALLOW on all actions +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + resource: photo + version: default + rules: + - actions: ["view"] + effect: EFFECT_ALLOW + roles: ["user"] + condition: + match: + any: + of: + - all: + of: + - expr: request.resource.attr.public == true + - expr: request.resource.attr.archived == false + - expr: request.resource.attr.ownerId == request.principal.id + + - actions: ["edit", "delete"] + effect: EFFECT_ALLOW + roles: ["user"] + condition: + match: + expr: request.resource.attr.ownerId == request.principal.id + + - actions: ["comment"] + effect: EFFECT_ALLOW + roles: ["user"] + condition: + match: + any: + of: + - all: + of: + - expr: request.resource.attr.public == true + - expr: request.resource.attr.archived == false + - expr: '"friends" in request.resource.attr.tags' + - expr: request.resource.attr.ownerId == request.principal.id + + - actions: ["view", "edit", "delete", "comment"] + effect: EFFECT_ALLOW + roles: ["admin"] diff --git a/spring-data/example/scripts/smoke.sh b/spring-data/example/scripts/smoke.sh new file mode 100755 index 00000000..f18220ca --- /dev/null +++ b/spring-data/example/scripts/smoke.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# End-to-end smoke test for the cerbos-spring-data photo-sharing example. +# +# Brings up the Cerbos PDP via docker compose, starts the Spring Boot app, and +# hits the REST endpoint with a handful of (user, role, action) tuples. Each +# request triggers a real PlanResources call to the PDP container — the audit +# log in `docker compose logs cerbos` then proves what plan the adapter saw. +# +# Pre-reqs: docker, curl, jq, gradle (8.x), JDK 17+. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +GREEN="\033[0;32m"; RED="\033[0;31m"; NC="\033[0m" +fail() { printf "${RED}FAIL${NC} %s\n" "$*" >&2; exit 1; } +ok() { printf "${GREEN}OK${NC} %s\n" "$*"; } + +cleanup() { + if [[ -n "${APP_PID:-}" ]]; then + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi + docker compose down --remove-orphans >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> docker compose up -d" +docker compose up -d + +echo "==> waiting for Cerbos health" +for i in {1..30}; do + if docker compose ps --format json cerbos | grep -q '"Health":"healthy"'; then break; fi + sleep 1 +done + +echo "==> gradle bootRun (background)" +mkdir -p build/smoke +gradle bootRun --no-daemon >build/smoke/app.log 2>&1 & +APP_PID=$! + +echo "==> waiting for Spring Boot on :8080" +for i in {1..60}; do + if curl -fsS "http://localhost:8080/photos?user=alice" >/dev/null 2>&1; then break; fi + sleep 1 +done +curl -fsS "http://localhost:8080/photos?user=alice" >/dev/null || \ + { tail -40 build/smoke/app.log; fail "Spring Boot didn't come up"; } + +assert_ids() { + local label=$1 url=$2 expected=$3 + local got + got=$(curl -fsS "$url" | jq -r '[.[].id] | sort | join(",")') + if [[ "$got" == "$expected" ]]; then + ok "$label => $got" + else + fail "$label expected=$expected got=$got" + fi +} + +# Seed data (from SeedData.java): +# p1 alice public !arch tags=travel,sunset +# p2 alice private !arch tags=friends,food +# p3 bob public arch tags=wedding +# p4 bob private !arch tags=portrait +# p5 charlie public !arch tags=travel,outdoors,friends +# p6 alice private arch tags=legacy +# +# view (user) : (public AND !archived) OR ownerId == self +# edit (user) : ownerId == self +# comment(user): (public AND !archived) OR "friends" in tags OR ownerId == self +# any (admin) : ALWAYS_ALLOWED => all 6 + +assert_ids "alice/view" "http://localhost:8080/photos?user=alice&action=view" "p1,p2,p5,p6" +assert_ids "alice/edit" "http://localhost:8080/photos?user=alice&action=edit" "p1,p2,p6" +assert_ids "alice/comment" "http://localhost:8080/photos?user=alice&action=comment" "p1,p2,p5,p6" +assert_ids "bob/view" "http://localhost:8080/photos?user=bob&action=view" "p1,p3,p4,p5" +assert_ids "bob/edit" "http://localhost:8080/photos?user=bob&action=edit" "p3,p4" +assert_ids "charlie/comment" "http://localhost:8080/photos?user=charlie&action=comment" "p1,p2,p5" +assert_ids "admin/view" "http://localhost:8080/photos?user=admin&role=admin&action=view" "p1,p2,p3,p4,p5,p6" +assert_ids "admin/delete" "http://localhost:8080/photos?user=admin&role=admin&action=delete" "p1,p2,p3,p4,p5,p6" + +echo +echo "==> PDP decision-log tail (proves PlanResources was hit, not stubbed):" +docker compose logs --tail=20 cerbos | grep -E '"planResources"|callId' || true + +echo +ok "all assertions passed" diff --git a/spring-data/example/settings.gradle.kts b/spring-data/example/settings.gradle.kts new file mode 100644 index 00000000..aef0481f --- /dev/null +++ b/spring-data/example/settings.gradle.kts @@ -0,0 +1,7 @@ +rootProject.name = "cerbos-spring-data-photos-example" + +// Consume the local cerbos-spring-data adapter source tree directly via a Gradle composite +// build — no need to publish the adapter to mavenLocal first. The included build's +// group/name/version (dev.cerbos:cerbos-spring-data:0.1.0-alpha.1) auto-substitutes for the +// declared dependency below. +includeBuild("..") diff --git a/spring-data/example/src/main/java/dev/cerbos/example/photos/CerbosClientConfig.java b/spring-data/example/src/main/java/dev/cerbos/example/photos/CerbosClientConfig.java new file mode 100644 index 00000000..aa78ebaa --- /dev/null +++ b/spring-data/example/src/main/java/dev/cerbos/example/photos/CerbosClientConfig.java @@ -0,0 +1,20 @@ +package dev.cerbos.example.photos; + +import dev.cerbos.sdk.CerbosBlockingClient; +import dev.cerbos.sdk.CerbosClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CerbosClientConfig { + + @Bean + CerbosBlockingClient cerbosBlockingClient( + @Value("${cerbos.host}") String host, + @Value("${cerbos.port}") int port) throws CerbosClientBuilder.InvalidClientConfigurationException { + return new CerbosClientBuilder(host + ":" + port) + .withPlaintext() + .buildBlockingClient(); + } +} diff --git a/spring-data/example/src/main/java/dev/cerbos/example/photos/Photo.java b/spring-data/example/src/main/java/dev/cerbos/example/photos/Photo.java new file mode 100644 index 00000000..8efdf903 --- /dev/null +++ b/spring-data/example/src/main/java/dev/cerbos/example/photos/Photo.java @@ -0,0 +1,65 @@ +package dev.cerbos.example.photos; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; + +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "photos") +public class Photo { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "owner_id", nullable = false) + private String ownerId; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "is_public", nullable = false) + private boolean isPublic; + + @Column(name = "is_archived", nullable = false) + private boolean isArchived; + + @Column(name = "location") + private String location; + + // EAGER so the controller can serialize tags after the @Transactional repository call — + // the example uses spring.jpa.open-in-view=false to avoid the lazy-init footgun. + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "photo_tags", joinColumns = @JoinColumn(name = "photo_id")) + @Column(name = "tag") + private Set tags = new HashSet<>(); + + public Photo() {} + + public Photo(String id, String ownerId, String title, boolean isPublic, boolean isArchived, + String location, Set tags) { + this.id = id; + this.ownerId = ownerId; + this.title = title; + this.isPublic = isPublic; + this.isArchived = isArchived; + this.location = location; + this.tags = tags; + } + + public String getId() { return id; } + public String getOwnerId() { return ownerId; } + public String getTitle() { return title; } + public boolean isPublic() { return isPublic; } + public boolean isArchived() { return isArchived; } + public String getLocation() { return location; } + public Set getTags() { return tags; } +} diff --git a/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoController.java b/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoController.java new file mode 100644 index 00000000..4740f1d2 --- /dev/null +++ b/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoController.java @@ -0,0 +1,36 @@ +package dev.cerbos.example.photos; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Set; + +@RestController +@RequestMapping("/photos") +public class PhotoController { + + public record PhotoView(String id, String ownerId, String title, boolean isPublic, + boolean isArchived, String location, Set tags) { + static PhotoView from(Photo p) { + return new PhotoView(p.getId(), p.getOwnerId(), p.getTitle(), p.isPublic(), + p.isArchived(), p.getLocation(), p.getTags()); + } + } + + private final PhotoService service; + + public PhotoController(PhotoService service) { + this.service = service; + } + + /** GET /photos?user=alice&role=user&action=view */ + @GetMapping + public List list(@RequestParam String user, + @RequestParam(defaultValue = "user") String role, + @RequestParam(defaultValue = "view") String action) { + return service.listAllowed(user, role, action).stream().map(PhotoView::from).toList(); + } +} diff --git a/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoRepository.java b/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoRepository.java new file mode 100644 index 00000000..9cbd7e47 --- /dev/null +++ b/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoRepository.java @@ -0,0 +1,8 @@ +package dev.cerbos.example.photos; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface PhotoRepository + extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoService.java b/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoService.java new file mode 100644 index 00000000..45a9ea83 --- /dev/null +++ b/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotoService.java @@ -0,0 +1,49 @@ +package dev.cerbos.example.photos; + +import dev.cerbos.queryplan.springdata.AttributeMapping; +import dev.cerbos.queryplan.springdata.Result; +import dev.cerbos.queryplan.springdata.SpringDataQueryPlanAdapter; +import dev.cerbos.sdk.CerbosBlockingClient; +import dev.cerbos.sdk.PlanResourcesResult; +import dev.cerbos.sdk.builders.Principal; +import dev.cerbos.sdk.builders.Resource; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Service +public class PhotoService { + + /** + * Maps Cerbos resource-attribute paths used in the policy to JPA paths on {@link Photo}. + * The Spring Data adapter translates each plan operand to {@code root.get(...)} via this + * mapping. {@code tags} is an {@code @ElementCollection} — declaring it as a + * relation makes the adapter emit a correlated {@code EXISTS} subquery for the CEL + * {@code "x" in tags} predicate. + */ + private static final Map PHOTO_ATTRS = Map.of( + "request.resource.attr.ownerId", AttributeMapping.field("ownerId"), + "request.resource.attr.public", AttributeMapping.field("isPublic"), + "request.resource.attr.archived", AttributeMapping.field("isArchived"), + "request.resource.attr.tags", AttributeMapping.relation("tags") + ); + + private final CerbosBlockingClient cerbos; + private final PhotoRepository repository; + + public PhotoService(CerbosBlockingClient cerbos, PhotoRepository repository) { + this.cerbos = cerbos; + this.repository = repository; + } + + public List listAllowed(String userId, String role, String action) { + PlanResourcesResult plan = cerbos.plan( + Principal.newInstance(userId).withRoles(role), + Resource.newInstance("photo"), + action); + + Result result = SpringDataQueryPlanAdapter.toSpecification(plan, PHOTO_ATTRS); + return repository.findAll(result.toSpecification()); + } +} diff --git a/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotosApplication.java b/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotosApplication.java new file mode 100644 index 00000000..c1fbbbac --- /dev/null +++ b/spring-data/example/src/main/java/dev/cerbos/example/photos/PhotosApplication.java @@ -0,0 +1,11 @@ +package dev.cerbos.example.photos; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PhotosApplication { + public static void main(String[] args) { + SpringApplication.run(PhotosApplication.class, args); + } +} diff --git a/spring-data/example/src/main/java/dev/cerbos/example/photos/SeedData.java b/spring-data/example/src/main/java/dev/cerbos/example/photos/SeedData.java new file mode 100644 index 00000000..f4d87d88 --- /dev/null +++ b/spring-data/example/src/main/java/dev/cerbos/example/photos/SeedData.java @@ -0,0 +1,34 @@ +package dev.cerbos.example.photos; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class SeedData implements CommandLineRunner { + + private final PhotoRepository repository; + + public SeedData(PhotoRepository repository) { + this.repository = repository; + } + + @Override + public void run(String... args) { + repository.saveAll(java.util.List.of( + new Photo("p1", "alice", "Beach sunset", true, false, "Lisbon", + Set.of("travel", "sunset")), + new Photo("p2", "alice", "Family lunch", false, false, "Home", + Set.of("friends", "food")), + new Photo("p3", "bob", "Wedding", true, true, "Paris", + Set.of("wedding")), + new Photo("p4", "bob", "Selfie", false, false, "Studio", + Set.of("portrait")), + new Photo("p5", "charlie", "Mountain hike", true, false, "Alps", + Set.of("travel", "outdoors", "friends")), + new Photo("p6", "alice", "Old archive", false, true, "Home", + Set.of("legacy")) + )); + } +} diff --git a/spring-data/example/src/main/resources/application.yaml b/spring-data/example/src/main/resources/application.yaml new file mode 100644 index 00000000..157aae19 --- /dev/null +++ b/spring-data/example/src/main/resources/application.yaml @@ -0,0 +1,23 @@ +spring: + datasource: + url: jdbc:h2:mem:photos;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: "" + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate.format_sql: true + open-in-view: false + +# Where the Cerbos PDP is listening. docker-compose.yml maps :3593 to localhost. +cerbos: + host: ${CERBOS_HOST:localhost} + port: ${CERBOS_PORT:3593} + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.orm.jdbc.bind: TRACE + dev.cerbos.example: INFO From ff5e4e0dc3021160452fe35c862e43c79204c537 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Wed, 24 Jun 2026 09:54:14 +0100 Subject: [PATCH 06/20] fix(spring-data): consistent operator overrides, empty-intersection guards, exists_one tests - Route every scalar-leaf emission (direct, add-folded, null-RHS, bare-boolean, isSet, scalar in) through the per-operator override hook, not just direct comparisons; document the overridable-operator set in OperatorFunction. - Short-circuit hasIntersection(...)/collectionContainsAny with an empty value list to an always-false predicate instead of a dialect-dependent empty IN (). - Add tests: exists_one (COUNT=1) coverage, empty-intersection shapes, and override-routing tests that prove each path consults the hook. - Sync spring-data.yaml action SHAs with main (checkout v7, setup-java v5.3.0, setup-gradle v6.2.0). All 172 tests pass, incl. 95 integration tests against a real Cerbos PDP. Signed-off-by: Alex Olivier --- .github/workflows/spring-data.yaml | 6 +- .../springdata/OperatorFunction.java | 13 ++ .../SpringDataQueryPlanAdapter.java | 50 +++++-- .../SpringDataQueryPlanAdapterTest.java | 127 ++++++++++++++++++ 4 files changed, 185 insertions(+), 11 deletions(-) diff --git a/.github/workflows/spring-data.yaml b/.github/workflows/spring-data.yaml index afe01e37..05b275e0 100644 --- a/.github/workflows/spring-data.yaml +++ b/.github/workflows/spring-data.yaml @@ -21,16 +21,16 @@ jobs: java-version: ["17", "21"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup JDK - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: temurin java-version: ${{ matrix.java-version }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Build and test run: gradle build --no-daemon diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java index f6eb5fc2..ef8150fb 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java @@ -7,6 +7,19 @@ /** * Override hook for translating a Cerbos operator + (field, value) pair into a JPA {@link Predicate}. * The {@code field} expression is already resolved to a typed JPA path (or join) under the current scope. + * + *

Overrides are keyed by Cerbos operator name and are consulted for every scalar leaf + * translation of that operator: {@code eq}, {@code ne}, {@code lt}, {@code gt}, {@code le}, + * {@code ge}, {@code contains}, {@code startsWith}, {@code endsWith} (including the {@code add}-folded + * forms such as {@code field == "p:" + R.id}, and the null-RHS form where {@code value} is + * {@code null}), the bare-boolean attribute (looked up as {@code eq}), {@code isSet} (where + * {@code value} is the {@link Boolean} flag), and the scalar {@code in} (where {@code value} is the + * resolved value or {@link java.util.List}). + * + *

Overrides are not consulted for operators that translate to correlated {@code EXISTS} + * subqueries against a {@code Relation} mapping — {@code exists}/{@code exists_one}/{@code all}/ + * {@code except}/{@code filter}, {@code hasIntersection} over a relation, {@code size(...)}, and the + * relation form of {@code in} — because those have no single resolved (field, value) pair. */ @FunctionalInterface public interface OperatorFunction { diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 2ce3a423..8f642010 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -109,11 +109,7 @@ Predicate traverse(Operand operand, Scope scope) { private Predicate handleBareVariable(String variable, Scope scope) { Path path = scope.resolvePath(variable); - OperatorFunction fn = overrides.get("eq"); - if (fn != null) { - return fn.apply(cb, path, true); - } - return cb.equal(path, true); + return applyLeaf("eq", path, true); } private Predicate traverseExpression(PlanResourcesFilter.Expression expression, Scope scope) { @@ -217,6 +213,11 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc Path path = scope.resolvePath(variable); if (value == null) { + // A registered override owns the operator's full translation, including a null RHS. + OperatorFunction override = overrides.get(op); + if (override != null) { + return override.apply(cb, path, null); + } return switch (op) { case "eq" -> cb.isNull(path); case "ne" -> cb.isNotNull(path); @@ -225,11 +226,19 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc }; } + return applyLeaf(op, path, value); + } + + /** + * Apply a scalar leaf operator, consulting the per-operator {@code overrides} hook first so a + * registered {@link OperatorFunction} wins on EVERY path that produces this operator — direct + * comparison, {@code add}-folded comparison, and bare-boolean — not just the direct one. + */ + private Predicate applyLeaf(String op, Path path, Object value) { OperatorFunction override = overrides.get(op); if (override != null) { return override.apply(cb, path, value); } - return defaultLeaf(op, path, value); } @@ -276,7 +285,7 @@ private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression "add(const, const) compared to a non-field operand is not supported"); } Path path = scope.resolvePath(otherOperand.getVariable()); - return defaultLeaf(op, path, folded); + return applyLeaf(op, path, folded); } // Case 2: add(field, value) or add(value, field) — solve for the field. @@ -317,7 +326,7 @@ private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression return "eq".equals(op) ? cb.disjunction() : cb.conjunction(); } Path path = scope.resolvePath(fieldOp.getVariable()); - return defaultLeaf(op, path, solved); + return applyLeaf(op, path, solved); } // -- isSet -- @@ -342,6 +351,10 @@ else if (o.getNodeCase() == Operand.NodeCase.VALUE) { throw new IllegalArgumentException("Invalid isSet operands"); } Path path = scope.resolvePath(variable); + OperatorFunction override = overrides.get("isSet"); + if (override != null) { + return override.apply(cb, path, flag); + } return flag ? cb.isNotNull(path) : cb.isNull(path); } @@ -366,6 +379,10 @@ private Predicate handleIn(List operands, Scope scope) { } Path path = scope.resolvePath(var); + OperatorFunction override = overrides.get("in"); + if (override != null) { + return override.apply(cb, path, val); + } if (val instanceof List list) { if (list.isEmpty()) { return cb.disjunction(); @@ -385,6 +402,10 @@ private Predicate handleIn(List operands, Scope scope) { return collectionContainsAny(scope, rel, List.of(val)); } Path path = scope.resolvePath(var); + OperatorFunction override = overrides.get("in"); + if (override != null) { + return override.apply(cb, path, val); + } return cb.equal(path, val); } @@ -412,6 +433,10 @@ private Predicate handleHasIntersection(List operands, Scope scope) { return collectionContainsAny(scope, rel, values); } Path path = scope.resolvePath(var); + // hasIntersection(field, []) is always false; avoid a dialect-dependent empty `IN ()`. + if (values.isEmpty()) { + return cb.disjunction(); + } return path.in(values); } @@ -423,6 +448,10 @@ private Predicate handleHasIntersection(List operands, Scope scope) { } Object val = protoValueToJava(second.getValue()); List values = (val instanceof List l) ? l : List.of(val); + // hasIntersection(map(...), []) is always false; short-circuit before the subquery. + if (values.isEmpty()) { + return cb.disjunction(); + } PlanResourcesFilter.Expression mapExpr = first.getExpression(); List mapOperands = mapExpr.getOperandsList(); @@ -482,6 +511,11 @@ private Predicate handleHasIntersection(List operands, Scope scope) { } private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relation rel, List values) { + // Intersection with an empty value set is always false — and an EXISTS wrapping an + // empty `IN ()` is dialect-dependent — so short-circuit before building the subquery. + if (values.isEmpty()) { + return cb.disjunction(); + } return existsSubquery(outerScope, rel, (sub, joinFrom) -> { Path field; if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index bcf3812b..f4696ca9 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -155,6 +155,41 @@ private static int runCount(Operand condition) { } } + /** {@link #runCount(Operand)} with per-operator overrides. */ + private static int runCount(Operand condition, Map overrides) { + PlanResourcesResponse resp = + buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, condition); + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER, overrides); + assertInstanceOf(Result.Conditional.class, result); + Specification spec = ((Result.Conditional) result).specification(); + + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(ResourceEntity.class); + cq.select(cb.count(root)); + Predicate p = spec.toPredicate(root, cq, cb); + if (p != null) cq.where(p); + return em.createQuery(cq).getSingleResult().intValue(); + } finally { + em.close(); + } + } + + /** Thrown by {@link #THROWING_OVERRIDE} to prove an override hook was actually invoked. */ + private static final class OverrideInvoked extends RuntimeException { + OverrideInvoked() { + super("override invoked"); + } + } + + /** An override that fails loudly when reached, so a test can assert the override path is taken. */ + private static final OperatorFunction THROWING_OVERRIDE = (cb, field, value) -> { + throw new OverrideInvoked(); + }; + @Test void alwaysAllowedResult() { PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_ALWAYS_ALLOWED, null); @@ -376,6 +411,98 @@ void hasIntersectionWithMap() { assertEquals(0, runCount(exprOp("hasIntersection", mapExpr, listOp("public", "private")))); } + @Test + void existsOneOnNestedRelation() { + // exists_one → correlated (SELECT COUNT(...)) = 1. Exercises the manual-correlation path + // that the other collection operators do not. + assertEquals(0, runCount(exprOp("exists_one", + var("request.resource.attr.tags"), + lambda("t", + exprOp("eq", var("t.name"), sval("public")))))); + } + + @Test + void existsOneWithCompoundBody() { + assertEquals(0, runCount(exprOp("exists_one", + var("request.resource.attr.tags"), + lambda("t", + exprOp("or", + exprOp("eq", var("t.id"), sval("tag1")), + exprOp("eq", var("t.name"), sval("public"))))))); + } + + // -- empty-list intersection short-circuits (no dialect-dependent `IN ()`) -- + + @Test + void hasIntersectionScalarEmptyListCompiles() { + // hasIntersection(field, []) is always false and must not emit an empty `IN ()`. + assertEquals(0, runCount(exprOp("hasIntersection", + var("request.resource.attr.aString"), listOp()))); + } + + @Test + void hasIntersectionRelationEmptyListCompiles() { + assertEquals(0, runCount(exprOp("hasIntersection", + var("request.resource.attr.tags"), listOp()))); + } + + @Test + void hasIntersectionMapEmptyListCompiles() { + Operand mapExpr = exprOp("map", + var("request.resource.attr.tags"), + lambda("t", var("t.name"))); + assertEquals(0, runCount(exprOp("hasIntersection", mapExpr, listOp()))); + } + + // -- override hook is consulted on every scalar-leaf path, not just the direct comparison -- + + @Test + void overrideAppliesToDirectComparison() { + Operand cond = exprOp("eq", var("request.resource.attr.aString"), sval("foo")); + assertThrows(OverrideInvoked.class, + () -> runCount(cond, Map.of("eq", THROWING_OVERRIDE))); + } + + @Test + void overrideAppliesToAddFoldedComparison() { + // field == "prefix:" + "123" folds to a constant then compares — must hit the same override. + Operand cond = exprOp("eq", + var("request.resource.attr.aString"), + exprOp("add", sval("prefix:"), sval("123"))); + assertThrows(OverrideInvoked.class, + () -> runCount(cond, Map.of("eq", THROWING_OVERRIDE))); + } + + @Test + void overrideAppliesToNullRhs() { + // eq(field, null) must route through a registered override rather than forcing IS NULL. + Operand cond = exprOp("eq", var("request.resource.attr.aOptionalString"), nullVal()); + assertThrows(OverrideInvoked.class, + () -> runCount(cond, Map.of("eq", THROWING_OVERRIDE))); + } + + @Test + void overrideAppliesToBareBoolean() { + Operand cond = var("request.resource.attr.aBool"); + assertThrows(OverrideInvoked.class, + () -> runCount(cond, Map.of("eq", THROWING_OVERRIDE))); + } + + @Test + void overrideAppliesToScalarIn() { + Operand cond = exprOp("in", + var("request.resource.attr.aString"), listOp("a", "b")); + assertThrows(OverrideInvoked.class, + () -> runCount(cond, Map.of("in", THROWING_OVERRIDE))); + } + + @Test + void overrideAppliesToIsSet() { + Operand cond = exprOp("isSet", var("request.resource.attr.aOptionalString"), bval(true)); + assertThrows(OverrideInvoked.class, + () -> runCount(cond, Map.of("isSet", THROWING_OVERRIDE))); + } + @Test void unknownAttributeThrows() { Operand cond = exprOp("eq", var("request.resource.attr.nonexistent"), sval("v")); From d218b5c096f6c2ac3d9d8a8270ddb67cde2bec55 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Wed, 24 Jun 2026 10:18:50 +0100 Subject: [PATCH 07/20] test(spring-data): assert 3-level nesting is correlated EXISTS; document MySQL LIKE escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a StatementInspector-backed integration test that runs the three-hop deep-nested-category-label policy (categories -> subCategories -> labels) and asserts the generated SQL has three nested correlated EXISTS subqueries and no cross join — a cartesian join would return rows but pair unrelated children. - README: document the MySQL/MariaDB LIKE backslash double-escaping gotcha and the NO_BACKSLASH_ESCAPES / OperatorFunction-override workarounds. Addresses the remaining #220 follow-ups (hierarchy operators handled in #249). Signed-off-by: Alex Olivier --- spring-data/README.md | 19 +++++++ .../springdata/SpringDataIntegrationTest.java | 49 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/spring-data/README.md b/spring-data/README.md index 03bb8b8b..63e822de 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -217,6 +217,25 @@ rejects a `Predicate` produced against a stale root with `SqlTreeCreationException: Could not locate TableGroup`. Pass the Specification to repository methods; don't cache the `Predicate` it returns. +### MySQL / MariaDB `LIKE` backslash escaping + +`contains` / `startsWith` / `endsWith` translate to `cb.like(path, pattern, '\\')` — the +adapter escapes `%`, `_`, and `\` in the user value and declares `\` as the SQL escape +character (the three-arg `LIKE … ESCAPE '\'` form). On most databases this is exact and +unambiguous. + +MySQL and MariaDB are the exception: by default they **also** treat `\` as an escape +character *inside the string literal itself*, so the escape is effectively applied twice and +a literal backslash in the attribute value can match incorrectly. If your data contains +backslashes and you target MySQL/MariaDB, either: + +- run the server with [`NO_BACKSLASH_ESCAPES`](https://dev.mysql.com/doc/refman/en/sql-mode.html#sqlmode_no_backslash_escapes) + enabled (Hibernate 6.4+ emits standard-conforming escaping in that mode), or +- register an `OperatorFunction` override for `contains`/`startsWith`/`endsWith` that builds + the `LIKE` predicate with an escape character your dialect handles cleanly. + +Values without backslashes are unaffected. + ## Build From the `spring-data/` directory: diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 2f0c01d8..26b95b7b 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -43,6 +43,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -149,6 +150,18 @@ class SpringDataIntegrationTest { Map.entry("request.resource.attr.nested.aNumber", AttributeMapping.field("nested.aNumber")) ); + /** Records every SQL statement Hibernate executes, so a test can assert on query shape. */ + public static final class SqlCapture implements org.hibernate.resource.jdbc.spi.StatementInspector { + static final java.util.List STATEMENTS = + java.util.Collections.synchronizedList(new java.util.ArrayList<>()); + + @Override + public String inspect(String sql) { + STATEMENTS.add(sql); + return sql; + } + } + private static GenericContainer createCerbosContainer() { GenericContainer container = new GenericContainer<>("ghcr.io/cerbos/cerbos:latest") .withExposedPorts(3593) @@ -198,7 +211,10 @@ static void setUp() throws Exception { cerbosClient = new CerbosClientBuilder(host + ":" + port) .withPlaintext().buildBlockingClient(); - emf = Persistence.createEntityManagerFactory("test-pu"); + // Install a StatementInspector so tests can assert on the generated SQL (e.g. that deeply + // nested correlated EXISTS subqueries don't degrade into a cartesian/cross join). + emf = Persistence.createEntityManagerFactory("test-pu", + Map.of("hibernate.session_factory.statement_inspector", SqlCapture.class.getName())); seedData(); } @@ -754,6 +770,37 @@ void hasIntersectionNested() { assertEquals(List.of("1", "2", "3"), runWithMapping("has-intersection-nested", CATEGORIES_MAP)); } + + @Test + void threeLevelNestingIsCorrelatedNotCrossJoined() { + // categories.exists(c, c.subCategories.exists(s, s.labels.exists(l, l.name == "important"))) + // — three relation hops, each a correlated EXISTS one level deeper than the last. This pins + // the generated SQL shape: a cross/cartesian join would still return rows but would wrongly + // pair unrelated subcategories/labels, so we assert directly on the SQL, not just the result. + SqlCapture.STATEMENTS.clear(); + assertEquals(List.of("1", "3"), + runWithMapping("deep-nested-category-label", CATEGORIES_MAP)); + + String sql = SqlCapture.STATEMENTS.stream() + .filter(s -> s.toLowerCase().contains("exists")) + .reduce("", (a, b) -> a.length() >= b.length() ? a : b) + .toLowerCase(); + assertFalse(sql.isEmpty(), "expected a SELECT with EXISTS to be captured"); + // One correlated EXISTS per relation hop (categories -> subCategories -> labels). + assertEquals(3, countOccurrences(sql, "exists"), + "expected three nested EXISTS subqueries, SQL was:\n" + sql); + // Correlated subqueries must not collapse into a cartesian product. + assertFalse(sql.contains("cross join"), + "nested correlation degraded into a cross join, SQL was:\n" + sql); + } + + private int countOccurrences(String haystack, String needle) { + int count = 0; + for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + needle.length())) { + count++; + } + return count; + } } // -- Single-valued (@ManyToOne) relation: resource.creator.{name,department} via dotted Field -- From 8b9ad8e974d8b637e770a358f5d7f97a4ddeddee Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Thu, 16 Jul 2026 09:00:04 +0100 Subject: [PATCH 08/20] feat(spring-data): hierarchy operators (overlaps / ancestorOf / descendentOf) (#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #220 (base: `spring-data`). Adds the hierarchy operators that were listed as a follow-up in #220, bringing the Spring Data adapter to parity with the Prisma adapter. ## What A Cerbos hierarchy is a delimited path (e.g. `a:b:c`). Each side of a hierarchy operator is wrapped in a `hierarchy(...)` expression resolving to one of: - a **constant** delimited string split into segments, - a **field** whose column holds the whole delimited string, or - a **`list(...)`** of segments, each a constant or a field. | Operator | Meaning | JPA Criteria translation | |---|---|---| | `ancestorOf(A, B)` | A is a strict prefix of B | constant→field: `B LIKE 'A.%'`; field→constant: `A IN (strictPrefixes(B))` | | `descendentOf(A, B)` | B is a strict prefix of A | symmetric to `ancestorOf` with operands swapped | | `overlaps(A, B)` | one is a prefix of the other | segment-wise equality, or for a whole-column field: `field IN (prefixes) OR field = raw OR field LIKE 'raw.%'` | Constant-only operands collapse to an always-true (`cb.conjunction()`) or always-false (`cb.disjunction()`) predicate. LIKE prefixes reuse the same `_`/`%`/`\` escaping as the string operators. Mirrors the Prisma adapter's algorithm so behaviour is consistent across adapters. ## Tests - **11 unit tests** (`SpringDataQueryPlanAdapterTest.HierarchyOperators`) covering every constant/field/segmented combination, the always-true/false collapses, strict-prefix `IN`, and the error contracts (non-hierarchy operands, two field-reference hierarchies, unsatisfiable constants). - **3 integration tests** (`SpringDataIntegrationTest.HierarchyOperators`) against a **real Cerbos PDP** exercising the `hierarchy-overlaps` / `hierarchy-ancestor-of` / `hierarchy-descendent-of` policy actions in `/policies/resource.yaml` — this validates that the operator names, `hierarchy(...)`/`list(...)` shapes, `R.id`, and principal-attribute substitution all match what the planner emits. - Adds a `scope` column to the test `ResourceEntity` for the ancestor/descendent fixtures. **186 tests pass** (incl. 98 integration tests against a real PDP), verified locally via the gradle Docker image with Testcontainers. Once #220 merges, this can be retargeted to `main`. Signed-off-by: Alex Olivier --- .../SpringDataQueryPlanAdapter.java | 285 ++++++++++++++++++ .../springdata/SpringDataIntegrationTest.java | 48 +++ .../SpringDataQueryPlanAdapterTest.java | 119 ++++++++ .../springdata/testmodel/ResourceEntity.java | 5 + 4 files changed, 457 insertions(+) diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 8f642010..5fe063d8 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -132,6 +132,9 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, case "hasIntersection" -> handleHasIntersection(operands, scope); case "isSet" -> handleIsSet(operands, scope); case "in" -> handleIn(operands, scope); + case "overlaps" -> handleOverlaps(operands, scope); + case "ancestorOf" -> handleAncestorDescendant(operands, scope, true); + case "descendentOf" -> handleAncestorDescendant(operands, scope, false); default -> { Predicate sizePred = trySizeComparison(op, operands, scope); if (sizePred != null) { @@ -682,6 +685,269 @@ private Predicate existsSubquery(Scope scope, AttributeMapping.Relation rel, Sub sub.where(body); return cb.exists(sub); } + + // -- hierarchy operators (overlaps / ancestorOf / descendentOf) -- + // + // A Cerbos hierarchy is a delimited path (e.g. "a:b:c"). Both sides of a hierarchy + // operator are wrapped in a `hierarchy(...)` expression that resolves to one of: + // - a constant string split into segments, + // - a single field whose column holds the whole delimited string, or + // - a `list(...)` of segments, each a constant or a field. + // The translations mirror the Prisma adapter so behaviour is consistent across adapters. + + private Predicate handleOverlaps(List operands, Scope scope) { + Hierarchy[] both = extractHierarchyOperands("overlaps", operands, scope); + Hierarchy left = both[0]; + Hierarchy right = both[1]; + + if (left instanceof Hierarchy.FieldRef || right instanceof Hierarchy.FieldRef) { + return handleFieldOverlaps(left, right); + } + + List leftSegs = toSegments(left); + List rightSegs = toSegments(right); + + List leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs); + List rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs); + + java.util.List> valid = new java.util.ArrayList<>(); + if (leftPrefixOfRight != null) valid.add(leftPrefixOfRight); + if (rightPrefixOfLeft != null) valid.add(rightPrefixOfLeft); + + if (valid.isEmpty()) { + // Neither side can be a prefix of the other. If a field is involved the overlap is + // simply never satisfiable (always-false); two incompatible constants are a planner bug. + boolean hasField = containsFieldSegment(leftSegs) || containsFieldSegment(rightSegs); + if (hasField) { + return cb.disjunction(); + } + throw new IllegalArgumentException("Cannot determine hierarchy overlap: no field references found"); + } + // An empty condition list means every compared segment was a matching constant — overlap + // holds unconditionally. + for (List c : valid) { + if (c.isEmpty()) { + return cb.conjunction(); + } + } + // Both directions (equal-length hierarchies) compare the same segment pairs, so either + // condition set is equivalent; use the first. + List chosen = valid.get(0); + return chosen.size() == 1 ? chosen.get(0) : cb.and(chosen.toArray(Predicate[]::new)); + } + + private Predicate handleFieldOverlaps(Hierarchy left, Hierarchy right) { + if (left instanceof Hierarchy.FieldRef && right instanceof Hierarchy.FieldRef) { + throw new IllegalArgumentException("overlaps: cannot compare two field-reference hierarchies"); + } + Hierarchy.FieldRef field = (left instanceof Hierarchy.FieldRef f) ? f : (Hierarchy.FieldRef) right; + Hierarchy other = (left instanceof Hierarchy.FieldRef) ? right : left; + if (!(other instanceof Hierarchy.Constant constant)) { + throw new IllegalArgumentException( + "overlaps: segmented hierarchies with field hierarchies are not supported"); + } + + String delimiter = field.delimiter(); + String otherRaw = String.join(delimiter, constant.segments()); + List strictPrefixes = getStrictPrefixes(constant.segments(), delimiter); + + java.util.List conditions = new java.util.ArrayList<>(); + // field is an ancestor of the constant... + if (!strictPrefixes.isEmpty()) { + conditions.add(field.path().in(strictPrefixes)); + } + // ...or equal to it... + conditions.add(cb.equal(field.path(), otherRaw)); + // ...or a descendant of it. + conditions.add(startsWithLiteral(field.path(), otherRaw + delimiter)); + + return conditions.size() == 1 ? conditions.get(0) : cb.or(conditions.toArray(Predicate[]::new)); + } + + private Predicate handleAncestorDescendant(List operands, Scope scope, boolean isAncestor) { + String opName = isAncestor ? "ancestorOf" : "descendentOf"; + Hierarchy[] both = extractHierarchyOperands(opName, operands, scope); + // ancestorOf(A, B) ⇔ A is a strict prefix of B; descendentOf(A, B) ⇔ B is a strict prefix of A. + Hierarchy ancestor = isAncestor ? both[0] : both[1]; + Hierarchy descendant = isAncestor ? both[1] : both[0]; + + if (ancestor instanceof Hierarchy.Constant a && descendant instanceof Hierarchy.FieldRef d) { + String prefix = String.join(d.delimiter(), a.segments()) + d.delimiter(); + return startsWithLiteral(d.path(), prefix); + } + if (ancestor instanceof Hierarchy.FieldRef a && descendant instanceof Hierarchy.Constant d) { + List prefixes = getStrictPrefixes(d.segments(), a.delimiter()); + if (prefixes.isEmpty()) { + return cb.disjunction(); + } + if (prefixes.size() == 1) { + return cb.equal(a.path(), prefixes.get(0)); + } + return a.path().in(prefixes); + } + if (ancestor instanceof Hierarchy.Constant a && descendant instanceof Hierarchy.Constant d) { + if (d.segments().size() > a.segments().size() + && isPrefix(a.segments(), d.segments())) { + return cb.conjunction(); + } + throw new IllegalArgumentException( + opName + ": constant operands do not satisfy the " + (isAncestor ? "ancestor" : "descendant") + + " relationship"); + } + throw new IllegalArgumentException(opName + ": unsupported hierarchy operand combination"); + } + + private Hierarchy[] extractHierarchyOperands(String opName, List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException(opName + " requires exactly 2 operands"); + } + return new Hierarchy[]{ + normalizeHierarchy(resolveHierarchy(opName, operands.get(0), scope)), + normalizeHierarchy(resolveHierarchy(opName, operands.get(1), scope)), + }; + } + + private Hierarchy resolveHierarchy(String opName, Operand operand, Scope scope) { + if (operand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"hierarchy".equals(operand.getExpression().getOperator())) { + throw new IllegalArgumentException(opName + " requires hierarchy(...) operands"); + } + List ops = operand.getExpression().getOperandsList(); + if (ops.size() == 2) { + Operand strOp = ops.get(0); + Operand delimOp = ops.get(1); + if (delimOp.getNodeCase() != Operand.NodeCase.VALUE) { + throw new IllegalArgumentException("hierarchy delimiter must be a value"); + } + String delimiter = String.valueOf(protoValueToJava(delimOp.getValue())); + if (strOp.getNodeCase() == Operand.NodeCase.VALUE) { + String raw = String.valueOf(protoValueToJava(strOp.getValue())); + return new Hierarchy.Constant(splitLiteral(raw, delimiter), delimiter); + } + if (strOp.getNodeCase() == Operand.NodeCase.VARIABLE) { + return new Hierarchy.FieldRef(scope.resolvePath(strOp.getVariable()), delimiter); + } + throw new IllegalArgumentException("hierarchy(string, delimiter) requires a value or field operand"); + } + if (ops.size() == 1) { + Operand inner = ops.get(0); + return switch (inner.getNodeCase()) { + case VALUE -> new Hierarchy.Constant( + splitLiteral(String.valueOf(protoValueToJava(inner.getValue())), "."), "."); + case VARIABLE -> new Hierarchy.FieldRef(scope.resolvePath(inner.getVariable()), "."); + case EXPRESSION -> { + if (!"list".equals(inner.getExpression().getOperator())) { + throw new IllegalArgumentException("hierarchy requires a value, field, or list operand"); + } + java.util.List segs = new java.util.ArrayList<>(); + for (Operand seg : inner.getExpression().getOperandsList()) { + switch (seg.getNodeCase()) { + case VALUE -> segs.add(new Seg.Const(String.valueOf(protoValueToJava(seg.getValue())))); + case VARIABLE -> segs.add(new Seg.FieldSeg(scope.resolvePath(seg.getVariable()))); + default -> throw new IllegalArgumentException( + "hierarchy list segment must be a value or field, got " + seg.getNodeCase()); + } + } + yield new Hierarchy.Segmented(segs); + } + default -> throw new IllegalArgumentException( + "hierarchy requires a value, field, or list operand, got " + inner.getNodeCase()); + }; + } + throw new IllegalArgumentException("hierarchy requires 1 or 2 operands"); + } + + /** Collapse an all-constant segmented hierarchy to a plain Constant (default delimiter). */ + private Hierarchy normalizeHierarchy(Hierarchy h) { + if (!(h instanceof Hierarchy.Segmented seg)) { + return h; + } + java.util.List values = new java.util.ArrayList<>(); + for (Seg s : seg.segments()) { + if (s instanceof Seg.Const c) { + values.add(c.value()); + } else { + return h; + } + } + return new Hierarchy.Constant(values, "."); + } + + private List toSegments(Hierarchy h) { + if (h instanceof Hierarchy.Constant c) { + return c.segments().stream().map(s -> (Seg) new Seg.Const(s)).toList(); + } + if (h instanceof Hierarchy.Segmented s) { + return s.segments(); + } + throw new IllegalArgumentException("Cannot enumerate segments of a field-reference hierarchy"); + } + + /** + * If {@code shorter} is a prefix of {@code longer}, return the predicates that must hold for + * the field segments to line up (an empty list = unconditionally true). Returns {@code null} + * if {@code shorter} cannot be a prefix of {@code longer}. + */ + private List checkPrefixConditions(List shorter, List longer) { + if (shorter.size() > longer.size()) { + return null; + } + java.util.List conditions = new java.util.ArrayList<>(); + for (int i = 0; i < shorter.size(); i++) { + Seg s = shorter.get(i); + Seg l = longer.get(i); + if (s instanceof Seg.Const sc && l instanceof Seg.Const lc) { + if (!sc.value().equals(lc.value())) { + return null; + } + } else if (s instanceof Seg.FieldSeg sf && l instanceof Seg.Const lc) { + conditions.add(cb.equal(sf.path(), lc.value())); + } else if (s instanceof Seg.Const sc && l instanceof Seg.FieldSeg lf) { + conditions.add(cb.equal(lf.path(), sc.value())); + } else { + throw new IllegalArgumentException( + "Cannot compare two field references in a hierarchy overlap"); + } + } + return conditions; + } + + private Predicate startsWithLiteral(Path path, String prefix) { + return cb.like(path.as(String.class), escapeLike(prefix) + "%", '\\'); + } + + private static boolean containsFieldSegment(List segs) { + return segs.stream().anyMatch(s -> s instanceof Seg.FieldSeg); + } + + private static boolean isPrefix(List shorter, List longer) { + for (int i = 0; i < shorter.size(); i++) { + if (!shorter.get(i).equals(longer.get(i))) { + return false; + } + } + return true; + } + + /** All proper (strict) ancestor prefixes of a segment list, joined with {@code delimiter}. */ + private static List getStrictPrefixes(List segments, String delimiter) { + if (segments.size() <= 1) { + return List.of(); + } + java.util.List prefixes = new java.util.ArrayList<>(); + String current = segments.get(0); + prefixes.add(current); + for (int i = 1; i < segments.size() - 1; i++) { + current = current + delimiter + segments.get(i); + prefixes.add(current); + } + return prefixes; + } + + /** Split on a literal delimiter (not a regex), keeping trailing empty segments. */ + private static List splitLiteral(String raw, String delimiter) { + return List.of(raw.split(java.util.regex.Pattern.quote(delimiter), -1)); + } } // -- Scope -- @@ -860,6 +1126,25 @@ private static AttributeMapping walkRelationChain(AttributeMapping.Relation rel, */ record RelationChain(List relations, AttributeMapping.Field tail) {} + /** A resolved {@code hierarchy(...)} operand: a constant path, a whole-column field, or a list of segments. */ + private sealed interface Hierarchy permits Hierarchy.Constant, Hierarchy.FieldRef, Hierarchy.Segmented { + /** A literal delimited path split into segments. */ + record Constant(List segments, String delimiter) implements Hierarchy {} + + /** A single column holding the whole delimited string. */ + record FieldRef(Path path, String delimiter) implements Hierarchy {} + + /** A {@code list(...)} of segments, each a constant or a field. */ + record Segmented(List segments) implements Hierarchy {} + } + + /** One segment of a {@link Hierarchy.Segmented}: either a literal value or a field reference. */ + private sealed interface Seg permits Seg.Const, Seg.FieldSeg { + record Const(String value) implements Seg {} + + record FieldSeg(Path path) implements Seg {} + } + private static RelationChain resolveRelationChain(Map mapper, String cerbosVar) { AttributeMapping direct = mapper.get(cerbosVar); if (direct instanceof AttributeMapping.Relation rel) { diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 26b95b7b..b89c8d50 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -162,6 +162,12 @@ public String inspect(String sql) { } } + // Hierarchy policies reference R.id (the resource id) and R.attr.scope (a delimited path column). + private static final Map HIERARCHY_MAP = Map.ofEntries( + Map.entry("request.resource.id", AttributeMapping.field("id")), + Map.entry("request.resource.attr.scope", AttributeMapping.field("scope")) + ); + private static GenericContainer createCerbosContainer() { GenericContainer container = new GenericContainer<>("ghcr.io/cerbos/cerbos:latest") .withExposedPorts(3593) @@ -270,6 +276,7 @@ private static void seedData() { r1.setaNumber(1); r1.setaOptionalString("hello"); r1.setCreatedBy("user1"); + r1.setScope("a.b.c"); r1.setOwnedBy(new java.util.ArrayList<>(List.of("user1", "user2"))); r1.setTagNames(new java.util.ArrayList<>(List.of("public", "featured"))); r1.addTag("tag1", "public"); @@ -293,6 +300,7 @@ private static void seedData() { r2.setaString("amIAString?"); r2.setaNumber(2); r2.setCreatedBy("user2"); + r2.setScope("a.x"); r2.setOwnedBy(new java.util.ArrayList<>(List.of("user2"))); r2.setTagNames(new java.util.ArrayList<>(List.of("private"))); r2.addTag("tag3", "private"); @@ -316,6 +324,7 @@ private static void seedData() { r3.setaNumber(3); r3.setaOptionalString("world"); r3.setCreatedBy("user3"); + r3.setScope("a.b"); r3.setOwnedBy(new java.util.ArrayList<>(List.of("user1"))); r3.setTagNames(new java.util.ArrayList<>(List.of("public"))); r3.addTag("tag1", "public"); @@ -1151,4 +1160,43 @@ void sizeOfFilterThrows() { assertActionThrows("filter-count-gt", NESTED_FIELD_MAP, "size()"); } } + + @Nested + class HierarchyOperators { + + // Resource scopes seeded above: r1="a.b.c", r2="a.x", r3="a.b". + + @Test + void overlaps() { + // Policy: hierarchy(P.attr.context, ":").overlaps(hierarchy(["projects", R.id])) + // With context "projects:1" the segments are ["projects","1"] and the field segment is + // R.id, so the overlap reduces to id == "1". + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("context", AttributeValue.stringValue("projects:1")); + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "hierarchy-overlaps", HIERARCHY_MAP)); + } + + @Test + void ancestorOf() { + // Policy: hierarchy(P.attr.scope).ancestorOf(hierarchy(R.attr.scope)) + // P.attr.scope "a.b" must be a strict prefix of R.attr.scope → scope LIKE 'a.b.%'. + // Only r1 ("a.b.c") is a strict descendant; r3 ("a.b") is equal (not strict). + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("scope", AttributeValue.stringValue("a.b")); + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "hierarchy-ancestor-of", HIERARCHY_MAP)); + } + + @Test + void descendentOf() { + // Policy: hierarchy(R.attr.scope).descendentOf(hierarchy(P.attr.scope)) + // R.attr.scope must be a strict descendant of P.attr.scope "a.b" — same result as + // ancestorOf above (the relation is symmetric across the two operators). + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("scope", AttributeValue.stringValue("a.b")); + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "hierarchy-descendent-of", HIERARCHY_MAP)); + } + } } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index f4696ca9..804c177a 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -830,6 +830,125 @@ void sizeOfFilterThrows() { } } + @Nested + class HierarchyOperators { + + // Helpers: a hierarchy(...) wrapper and a list(...) of segments. + private Operand hierarchy(Operand inner, String delimiter) { + return exprOp("hierarchy", inner, sval(delimiter)); + } + + private Operand hierarchy(Operand inner) { + return exprOp("hierarchy", inner); + } + + private Operand segList(Operand... segs) { + return exprOp("list", segs); + } + + @Test + void ancestorOfConstantPrefixOfField() { + // ancestorOf(hierarchy("a:b", ":"), hierarchy(field, ":")) → field LIKE 'a:b:%' + Operand cond = exprOp("ancestorOf", + hierarchy(sval("a:b"), ":"), + hierarchy(var("request.resource.attr.aString"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void ancestorOfFieldPrefixOfConstant() { + // ancestorOf(hierarchy(field, ":"), hierarchy("a:b:c", ":")) → field IN ('a', 'a:b') + Operand cond = exprOp("ancestorOf", + hierarchy(var("request.resource.attr.aString"), ":"), + hierarchy(sval("a:b:c"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void descendentOfFieldUnderConstant() { + // descendentOf(hierarchy(field, ":"), hierarchy("a:b", ":")) → field LIKE 'a:b:%' + Operand cond = exprOp("descendentOf", + hierarchy(var("request.resource.attr.aString"), ":"), + hierarchy(sval("a:b"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void overlapsFieldHierarchyWithConstant() { + // overlaps(hierarchy(field, ":"), hierarchy("a:b", ":")) + // → field IN ('a') OR field = 'a:b' OR field LIKE 'a:b:%' + Operand cond = exprOp("overlaps", + hierarchy(var("request.resource.attr.aString"), ":"), + hierarchy(sval("a:b"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void overlapsSegmentedWithField() { + // The policy shape: hierarchy("projects:123", ":").overlaps(hierarchy(["projects", R.id])) + // → segment-wise: const "projects" matches, then field == "123" + Operand cond = exprOp("overlaps", + hierarchy(sval("projects:123"), ":"), + hierarchy(segList(sval("projects"), var("request.resource.attr.aString")))); + assertEquals(0, runCount(cond)); + } + + @Test + void overlapsConstantsMatchingPrefixIsAlwaysTrue() { + // overlaps("a", "a:b") — "a" is a prefix of "a:b", all constant → unconditionally true. + Operand cond = exprOp("overlaps", + hierarchy(sval("a"), ":"), + hierarchy(sval("a:b"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void ancestorOfConstantsSatisfied() { + // ancestorOf("a", "a:b") — satisfied by constants alone → unconditionally true. + Operand cond = exprOp("ancestorOf", + hierarchy(sval("a"), ":"), + hierarchy(sval("a:b"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void overlapsIncompatibleConstantsWithoutFieldThrows() { + // overlaps("a:b", "x:y") — no prefix relationship and no field to constrain → planner bug. + assertConditionThrows( + exprOp("overlaps", + hierarchy(sval("a:b"), ":"), + hierarchy(sval("x:y"), ":")), + "Cannot determine hierarchy overlap"); + } + + @Test + void ancestorOfConstantsNotSatisfiedThrows() { + assertConditionThrows( + exprOp("ancestorOf", + hierarchy(sval("x"), ":"), + hierarchy(sval("a:b"), ":")), + "ancestorOf", "do not satisfy"); + } + + @Test + void nonHierarchyOperandThrows() { + assertConditionThrows( + exprOp("overlaps", + var("request.resource.attr.aString"), + sval("a:b")), + "overlaps", "hierarchy(...) operands"); + } + + @Test + void twoFieldHierarchiesInOverlapThrows() { + assertConditionThrows( + exprOp("overlaps", + hierarchy(var("request.resource.attr.aString"), ":"), + hierarchy(var("request.resource.attr.createdBy"), ":")), + "two field-reference hierarchies"); + } + } + @Test void operatorOverrideIsUsed() { Operand cond = exprOp("eq", var("request.resource.attr.aString"), sval("foo")); diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java index d1c029b1..25ddcfd6 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java @@ -43,6 +43,9 @@ public class ResourceEntity { @Column(name = "created_by") private String createdBy; + @Column(name = "scope") + private String scope; + @ElementCollection @CollectionTable(name = "resource_owned_by", joinColumns = @JoinColumn(name = "resource_id")) @Column(name = "owner") @@ -89,6 +92,8 @@ public ResourceEntity(String id) { public void setaOptionalString(String aOptionalString) { this.aOptionalString = aOptionalString; } public String getCreatedBy() { return createdBy; } public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } + public String getScope() { return scope; } + public void setScope(String scope) { this.scope = scope; } public List getOwnedBy() { return ownedBy; } public void setOwnedBy(List ownedBy) { this.ownedBy = ownedBy; } public List getTagNames() { return tagNames; } From 55b5c915452b637dbea2ca8a122564aebf386527 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Thu, 16 Jul 2026 09:20:45 +0100 Subject: [PATCH 09/20] fix(spring-data): honor planner operand order; resolve outer attrs in lambdas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The query planner preserves policy source order, so constants can precede fields in emitted expressions. The adapter resolved operands order-agnostically, which silently inverted directional comparisons: - mirror lt/le/gt/ge when the value precedes the field (1 < R.attr.x -> x > 1), including add-folded constants; overrides are consulted under the mirrored op - mirror size() comparisons when the size() expression is the right operand (0 < size(x) previously threw) - normalize hasIntersection(, ) — symmetric — and accept the deprecated has_intersection alias - delegate non-lambda variables in lambda bodies to the enclosing scope rooted at the correlated parent, so tags.exists(t, t.name == "x" && R.attr.aBool) translates instead of throwing - fix NPE on struct values containing nulls; clean IAE for malformed map() lambdas; drop dead PlanExpr class; correct AlwaysAllowed doc in README All shapes verified against a live PDP (v0.53.0). Adds value-first-lt/size/ intersect and outer-attr-in-lambda conformance actions to the shared policy fixture (additive) with matching integration tests, plus seeded unit tests that pin the mirrored semantics. 203 tests pass in the Docker/testcontainers suite. Signed-off-by: Alex Olivier --- policies/resource.yaml | 41 ++++ spring-data/README.md | 10 +- .../springdata/OperatorFunction.java | 4 + .../SpringDataQueryPlanAdapter.java | 163 ++++++++++++--- .../springdata/SpringDataIntegrationTest.java | 35 ++++ .../SpringDataQueryPlanAdapterTest.java | 186 ++++++++++++++++++ 6 files changed, 403 insertions(+), 36 deletions(-) diff --git a/policies/resource.yaml b/policies/resource.yaml index fed4c134..10db9857 100644 --- a/policies/resource.yaml +++ b/policies/resource.yaml @@ -942,4 +942,45 @@ resourcePolicy: expr: > hierarchy(R.attr.scope).descendentOf(hierarchy(P.attr.scope)) + # Operand-order conformance: the planner preserves policy source order, so these + # value-first shapes reach adapters with the constant as the FIRST operand. Directional + # operators must be mirrored by adapters or results are silently inverted. + - actions: + - "value-first-lt" + effect: EFFECT_ALLOW + roles: + - USER + condition: + match: + expr: 1 < R.attr.aNumber + + - actions: + - "value-first-size" + effect: EFFECT_ALLOW + roles: + - USER + condition: + match: + expr: 0 < size(R.attr.ownedBy) + + - actions: + - "value-first-intersect" + effect: EFFECT_ALLOW + roles: + - USER + condition: + match: + expr: hasIntersection(["user1", "userX"], R.attr.ownedBy) + + # A residual resource attribute INSIDE a collection lambda body — adapters must resolve + # it against the outer entity, not the collection element. + - actions: + - "outer-attr-in-lambda" + effect: EFFECT_ALLOW + roles: + - USER + condition: + match: + expr: R.attr.tags.exists(tag, tag.name == "public" && R.attr.aBool) + diff --git a/spring-data/README.md b/spring-data/README.md index 63e822de..8a00e287 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -71,11 +71,11 @@ List contacts = contactRepository.findAll(result.toSpecification()); `Result.toSpecification()` returns a Specification that captures all three plan kinds, so you don't need to switch on the result kind unless you want to short-circuit the DB hit: -| Kind | Specification | -|------------------------|----------------------------------------| -| `Result.AlwaysAllowed` | always-true predicate (`1=1`) | -| `Result.AlwaysDenied` | always-false predicate (`1=0`) | -| `Result.Conditional` | the translated predicate tree | +| Kind | Specification | +|------------------------|--------------------------------------------------------------| +| `Result.AlwaysAllowed` | `null` predicate — Spring Data omits the `WHERE` clause | +| `Result.AlwaysDenied` | always-false predicate (`1=0`) | +| `Result.Conditional` | the translated predicate tree | Compose it with your own filters: diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java index ef8150fb..26c13e09 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java @@ -16,6 +16,10 @@ * {@code value} is the {@link Boolean} flag), and the scalar {@code in} (where {@code value} is the * resolved value or {@link java.util.List}). * + *

Operand order is normalized before overrides are consulted: a value-first comparison such as + * {@code 5 < R.attr.x} is mirrored to field-first form, so the override is looked up (and invoked) + * under {@code gt}, matching the semantics of the predicate being built. + * *

Overrides are not consulted for operators that translate to correlated {@code EXISTS} * subqueries against a {@code Relation} mapping — {@code exists}/{@code exists_one}/{@code all}/ * {@code except}/{@code filter}, {@code hasIntersection} over a relation, {@code size(...)}, and the diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 5fe063d8..5eeb4f0f 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -18,7 +18,6 @@ import java.util.List; import java.util.Map; -import java.util.stream.Collectors; /** * Translates a Cerbos {@code PlanResources} response into a Spring Data JPA @@ -27,11 +26,6 @@ */ public final class SpringDataQueryPlanAdapter { - // Alias for the deeply-nested protobuf type to avoid collision with jakarta.persistence.criteria.Expression - private static final class PlanExpr { - private PlanExpr() {} - } - private SpringDataQueryPlanAdapter() {} // -- PlanResourcesResult overloads -- @@ -129,7 +123,8 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, } case "exists", "exists_one", "all", "except", "filter" -> handleCollectionOperator(op, operands, scope); - case "hasIntersection" -> handleHasIntersection(operands, scope); + // has_intersection is the deprecated pre-camelCase alias still accepted by the PDP. + case "hasIntersection", "has_intersection" -> handleHasIntersection(operands, scope); case "isSet" -> handleIsSet(operands, scope); case "in" -> handleIn(operands, scope); case "overlaps" -> handleOverlaps(operands, scope); @@ -145,6 +140,22 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, }; } + /** + * Mirror a directional comparison operator, for normalizing value-first operand order. + * The planner preserves policy source order, so {@code 5 < R.attr.x} arrives as + * {@code lt(value(5), variable(x))} — which must translate to {@code x > 5}, not + * {@code x < 5}. Symmetric operators (eq/ne/...) are returned unchanged. + */ + private static String mirrorOperator(String op) { + return switch (op) { + case "lt" -> "gt"; + case "gt" -> "lt"; + case "le" -> "ge"; + case "ge" -> "le"; + default -> op; + }; + } + // -- Leaf operators (eq/ne/lt/gt/le/ge/contains/startsWith/endsWith) -- private Predicate handleLeafOperator(String op, List operands, Scope scope) { @@ -153,10 +164,13 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc // the field side when possible — same algorithm as the Prisma adapter. Operand addExprOperand = null; Operand otherOperand = null; - for (Operand o : operands) { + boolean addIsFirst = false; + for (int i = 0; i < operands.size(); i++) { + Operand o = operands.get(i); if (o.getNodeCase() == Operand.NodeCase.EXPRESSION && "add".equals(o.getExpression().getOperator())) { addExprOperand = o; + addIsFirst = i == 0; } else { otherOperand = o; } @@ -165,13 +179,16 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc if (otherOperand == null) { throw new IllegalArgumentException("add comparison requires a second operand"); } - return handleAddComparison(op, addExprOperand.getExpression(), otherOperand, scope); + return handleAddComparison(op, addExprOperand.getExpression(), otherOperand, addIsFirst, scope); } String variable = null; + int variableIndex = -1; Object value = null; + int valueIndex = -1; boolean valueSeen = false; - for (Operand o : operands) { + for (int i = 0; i < operands.size(); i++) { + Operand o = operands.get(i); switch (o.getNodeCase()) { case VARIABLE -> { if (variable != null) { @@ -183,9 +200,11 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc + op + "': " + variable + " vs " + o.getVariable()); } variable = o.getVariable(); + variableIndex = i; } case VALUE -> { value = protoValueToJava(o.getValue()); + valueIndex = i; valueSeen = true; } case EXPRESSION -> { @@ -212,6 +231,11 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc if (!valueSeen) { throw new IllegalArgumentException("Missing value operand for " + op); } + // Value-first comparisons (`5 < R.attr.x`) must mirror the operator so the predicate + // is built field-first with equivalent semantics (`x > 5`). + if (valueIndex < variableIndex) { + op = mirrorOperator(op); + } Path path = scope.resolvePath(variable); @@ -269,7 +293,7 @@ private static String escapeLike(String s) { // -- add (fold + solve for string concat / numeric translation) -- private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression addExpr, - Operand otherOperand, Scope scope) { + Operand otherOperand, boolean addIsFirst, Scope scope) { List addOperands = addExpr.getOperandsList(); if (addOperands.size() != 2) { throw new IllegalArgumentException("add requires exactly 2 operands"); @@ -288,7 +312,8 @@ private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression "add(const, const) compared to a non-field operand is not supported"); } Path path = scope.resolvePath(otherOperand.getVariable()); - return applyLeaf(op, path, folded); + // `add(1, 2) < R.attr.x` means `3 < x` — mirror to build the predicate field-first. + return applyLeaf(addIsFirst ? mirrorOperator(op) : op, path, folded); } // Case 2: add(field, value) or add(value, field) — solve for the field. @@ -424,6 +449,15 @@ private Predicate handleHasIntersection(List operands, Scope scope) { } Operand first = operands.get(0); Operand second = operands.get(1); + // Intersection is symmetric, and the planner preserves policy source order — + // `hasIntersection(P.attr.tags, R.attr.tags)` folds the principal side to a value + // list in the FIRST position. Normalize to field/map-first. + if (first.getNodeCase() == Operand.NodeCase.VALUE + && second.getNodeCase() != Operand.NodeCase.VALUE) { + Operand tmp = first; + first = second; + second = tmp; + } if (first.getNodeCase() == Operand.NodeCase.VARIABLE && second.getNodeCase() == Operand.NodeCase.VALUE) { @@ -476,6 +510,9 @@ private Predicate handleHasIntersection(List operands, Scope scope) { PlanResourcesFilter.Expression lambdaExpr = lambdaOperand.getExpression(); List lambdaOps = lambdaExpr.getOperandsList(); + if (lambdaOps.size() != 2) { + throw new IllegalArgumentException("map lambda requires exactly 2 operands (body, variable)"); + } Operand projection = lambdaOps.get(0); Operand lambdaVar = lambdaOps.get(1); if (projection.getNodeCase() != Operand.NodeCase.VARIABLE @@ -491,7 +528,7 @@ private Predicate handleHasIntersection(List operands, Scope scope) { RelationChain chain = resolveRelationChain(rootScope.mapper(), collectionVar); if (chain != null && !chain.relations().isEmpty()) { AttributeMapping.Relation tailRel = chain.relations().get(chain.relations().size() - 1); - return chainedExistsSubquery(scope, chain.relations(), (sub, joinFrom) -> { + return chainedExistsSubquery(scope, chain.relations(), (sub, joinFrom, correlated) -> { Path field = resolveMemberPath(joinFrom, tailRel, memberField); return field.in(values); }); @@ -500,7 +537,7 @@ private Predicate handleHasIntersection(List operands, Scope scope) { AttributeMapping mapping = scope.resolveMapping(collectionVar); if (mapping instanceof AttributeMapping.Relation rel) { - return existsSubquery(scope, rel, (sub, joinFrom) -> { + return existsSubquery(scope, rel, (sub, joinFrom, correlated) -> { Path field = resolveMemberPath(joinFrom, rel, memberField); return field.in(values); }); @@ -519,7 +556,7 @@ private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relat if (values.isEmpty()) { return cb.disjunction(); } - return existsSubquery(outerScope, rel, (sub, joinFrom) -> { + return existsSubquery(outerScope, rel, (sub, joinFrom, correlated) -> { Path field; if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { field = joinFrom.get(rel.defaultMemberField()); @@ -538,11 +575,14 @@ private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relat private Predicate trySizeComparison(String op, List operands, Scope scope) { PlanResourcesFilter.Expression sizeExpr = null; + boolean sizeIsFirst = false; Long numValue = null; - for (Operand o : operands) { + for (int i = 0; i < operands.size(); i++) { + Operand o = operands.get(i); if (o.getNodeCase() == Operand.NodeCase.EXPRESSION && "size".equals(o.getExpression().getOperator())) { sizeExpr = o.getExpression(); + sizeIsFirst = i == 0; } else if (o.getNodeCase() == Operand.NodeCase.VALUE) { Object v = protoValueToJava(o.getValue()); if (v instanceof Number n) numValue = n.longValue(); @@ -551,6 +591,11 @@ private Predicate trySizeComparison(String op, List operands, Scope sco if (sizeExpr == null || numValue == null) { return null; } + // `0 < size(x)` arrives as lt(value(0), size(x)); mirror so the checks below can + // always assume the size() expression is on the left. + if (!sizeIsFirst) { + op = mirrorOperator(op); + } List sizeOps = sizeExpr.getOperandsList(); if (sizeOps.size() != 1 || sizeOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE) { throw new IllegalArgumentException("Unsupported size() expression"); @@ -567,10 +612,10 @@ private Predicate trySizeComparison(String op, List operands, Scope sco || ("lt".equals(op) && numValue == 1L); if (nonEmpty) { - return existsSubquery(scope, rel, (sub, joinFrom) -> cb.conjunction()); + return existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.conjunction()); } if (empty) { - return cb.not(existsSubquery(scope, rel, (sub, joinFrom) -> cb.conjunction())); + return cb.not(existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.conjunction())); } throw new IllegalArgumentException( "Unsupported size comparison: size(" + var + ") " + op + " " + numValue @@ -616,11 +661,14 @@ private Predicate handleCollectionOperator(String op, List operands, Sc return switch (op) { case "exists", "filter" -> existsSubquery(scope, rel, - (sub, joinFrom) -> traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))); + (sub, joinFrom, correlated) -> traverse(body, + Scope.lambda(joinFrom, sub, rel, lambdaVarName, rebase(scope, correlated, sub)))); case "except" -> existsSubquery(scope, rel, - (sub, joinFrom) -> cb.not(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName)))); + (sub, joinFrom, correlated) -> cb.not(traverse(body, + Scope.lambda(joinFrom, sub, rel, lambdaVarName, rebase(scope, correlated, sub))))); case "all" -> cb.not(existsSubquery(scope, rel, - (sub, joinFrom) -> cb.not(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))))); + (sub, joinFrom, correlated) -> cb.not(traverse(body, + Scope.lambda(joinFrom, sub, rel, lambdaVarName, rebase(scope, correlated, sub)))))); case "exists_one" -> { Subquery sub = scope.parentQuery().subquery(Long.class); From outerFrom = scope.from(); @@ -634,7 +682,8 @@ private Predicate handleCollectionOperator(String op, List operands, Sc } Join joinFrom = correlated.join(rel.joinAttribute()); sub.select(cb.count(joinFrom)); - sub.where(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))); + sub.where(traverse(body, + Scope.lambda(joinFrom, sub, rel, lambdaVarName, rebase(scope, correlated, sub)))); yield cb.equal(sub, 1L); } default -> throw new IllegalArgumentException("Unsupported collection operator: " + op); @@ -643,7 +692,26 @@ private Predicate handleCollectionOperator(String op, List operands, Sc @FunctionalInterface private interface SubqueryBodyBuilder { - Predicate build(Subquery sub, From joinFrom); + /** + * @param sub the subquery being built + * @param joinFrom the join over the Relation's collection inside the subquery + * @param correlated the outer entity correlated into the subquery — lambda bodies + * resolve non-lambda variables (e.g. {@code request.resource.attr.x}) + * against this so outer references stay legal JPA correlation paths + */ + Predicate build(Subquery sub, From joinFrom, From correlated); + } + + /** + * Re-root {@code scope} at the correlated copy of its {@code from} inside a subquery, so + * paths resolved through it become valid correlation references of that subquery. + */ + private static Scope rebase(Scope scope, From correlated, Subquery sub) { + if (scope instanceof Scope.RootScope rs) { + return new Scope.RootScope(correlated, sub, rs.mapper()); + } + Scope.LambdaScope ls = (Scope.LambdaScope) scope; + return new Scope.LambdaScope(correlated, sub, ls.relation(), ls.lambdaVar(), ls.outer()); } /** @@ -657,12 +725,13 @@ private Predicate chainedExistsSubquery(Scope scope, if (chain.size() == 1) { return existsSubquery(scope, chain.get(0), bodyBuilder); } - return existsSubquery(scope, chain.get(0), (sub, joinFrom) -> { + return existsSubquery(scope, chain.get(0), (sub, joinFrom, correlated) -> { // Recurse using an intermediate scope rooted at the current join + this subquery. // The lambda variable name is internal-only — `$` is not a valid CEL identifier // character, so this sentinel can never collide with a user-supplied lambda name. AttributeMapping.Relation thisRel = chain.get(0); - Scope intermediate = Scope.lambda(joinFrom, sub, thisRel, "$$chain$$"); + Scope intermediate = Scope.lambda(joinFrom, sub, thisRel, "$$chain$$", + rebase(scope, correlated, sub)); return chainedExistsSubquery(intermediate, chain.subList(1, chain.size()), bodyBuilder); }); } @@ -681,7 +750,7 @@ private Predicate existsSubquery(Scope scope, AttributeMapping.Relation rel, Sub } Join joinFrom = correlated.join(rel.joinAttribute()); sub.select(cb.literal(1)); - Predicate body = bodyBuilder.build(sub, joinFrom); + Predicate body = bodyBuilder.build(sub, joinFrom, correlated); sub.where(body); return cb.exists(sub); } @@ -966,8 +1035,8 @@ static Scope root(From root, AbstractQuery query, Map from, AbstractQuery parentQuery, - AttributeMapping.Relation relation, String lambdaVar) { - return new LambdaScope(from, parentQuery, relation, lambdaVar); + AttributeMapping.Relation relation, String lambdaVar, Scope outer) { + return new LambdaScope(from, parentQuery, relation, lambdaVar, outer); } record RootScope(From from, AbstractQuery parentQuery, Map mapper) @@ -1012,10 +1081,30 @@ public AttributeMapping resolveMapping(String cerbosVar) { } } + /** + * Scope inside a collection lambda. Variables prefixed with the lambda variable resolve + * against the joined collection element; anything else (e.g. another + * {@code request.resource.attr.*} reference in the lambda body) delegates to {@code outer} + * — the enclosing scope re-rooted at the subquery's correlated parent, so the produced + * path is a legal correlation reference. + */ record LambdaScope(From from, AbstractQuery parentQuery, - AttributeMapping.Relation relation, String lambdaVar) implements Scope { + AttributeMapping.Relation relation, String lambdaVar, + Scope outer) implements Scope { + + private boolean isLambdaRef(String cerbosVar) { + return cerbosVar.equals(lambdaVar) || cerbosVar.startsWith(lambdaVar + "."); + } + @Override public Path resolvePath(String cerbosVar) { + if (!isLambdaRef(cerbosVar)) { + if (outer != null) { + return outer.resolvePath(cerbosVar); + } + throw new IllegalArgumentException( + "Variable '" + cerbosVar + "' does not start with lambda variable '" + lambdaVar + "'"); + } String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); if (suffix.isEmpty()) { if (relation.defaultMemberField() != null && !relation.defaultMemberField().isEmpty()) { @@ -1032,6 +1121,13 @@ public Path resolvePath(String cerbosVar) { @Override public AttributeMapping resolveMapping(String cerbosVar) { + if (!isLambdaRef(cerbosVar)) { + if (outer != null) { + return outer.resolveMapping(cerbosVar); + } + throw new IllegalArgumentException( + "Variable '" + cerbosVar + "' does not start with lambda variable '" + lambdaVar + "'"); + } String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); if (suffix.isEmpty()) { return relation; @@ -1239,8 +1335,13 @@ static Object protoValueToJava(Value value) { case LIST_VALUE -> value.getListValue().getValuesList().stream() .map(SpringDataQueryPlanAdapter::protoValueToJava) .toList(); - case STRUCT_VALUE -> value.getStructValue().getFieldsMap().entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> protoValueToJava(e.getValue()))); + case STRUCT_VALUE -> { + // Not Collectors.toMap: it rejects null values, and struct fields may hold nulls. + Map struct = new java.util.LinkedHashMap<>(); + value.getStructValue().getFieldsMap() + .forEach((k, v) -> struct.put(k, protoValueToJava(v))); + yield struct; + } case KIND_NOT_SET -> throw new IllegalArgumentException( "Protobuf Value has no kind set — the planner emitted a malformed operand"); default -> throw new IllegalArgumentException( diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index b89c8d50..050085c7 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -1161,6 +1161,41 @@ void sizeOfFilterThrows() { } } + // -- Operand order: value-first comparisons and outer references inside lambdas -- + // The planner preserves policy source order; these actions pin the shapes end-to-end. + + @Nested + class OperandOrder { + + @Test + void valueFirstLt() { + // 1 < aNumber → aNumber > 1 → r2 (2), r3 (3). An unmirrored translation + // (aNumber < 1) would return []. + assertEquals(List.of("2", "3"), run("value-first-lt")); + } + + @Test + void valueFirstSize() { + // 0 < size(ownedBy) → non-empty → all three. + assertEquals(List.of("1", "2", "3"), run("value-first-size")); + } + + @Test + void valueFirstIntersect() { + // hasIntersection(["user1","userX"], ownedBy) → r1 [user1,user2], r3 [user1]. + assertEquals(List.of("1", "3"), run("value-first-intersect")); + } + + @Test + void outerAttributeInsideLambda() { + // tags.exists(tag, tag.name == "public" && R.attr.aBool) + // r1: aBool=true, has public tag → ✓ + // r2: aBool=false → ✗ + // r3: aBool=true, has public tag → ✓ + assertEquals(List.of("1", "3"), runNested("outer-attr-in-lambda")); + } + } + @Nested class HierarchyOperators { diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index 804c177a..9263e130 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -178,6 +178,27 @@ private static int runCount(Operand condition, Map ove } } + /** Persist {@code entity}, run {@code body}, then always delete the row again. */ + private static void withResource(ResourceEntity entity, Runnable body) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(entity); + em.getTransaction().commit(); + em.close(); + try { + body.run(); + } finally { + EntityManager cleanup = emf.createEntityManager(); + cleanup.getTransaction().begin(); + ResourceEntity managed = cleanup.find(ResourceEntity.class, entity.getId()); + if (managed != null) { + cleanup.remove(managed); + } + cleanup.getTransaction().commit(); + cleanup.close(); + } + } + /** Thrown by {@link #THROWING_OVERRIDE} to prove an override hook was actually invoked. */ private static final class OverrideInvoked extends RuntimeException { OverrideInvoked() { @@ -949,6 +970,171 @@ void twoFieldHierarchiesInOverlapThrows() { } } + // -- Operand order: the planner preserves policy source order, so a value (or folded + // constant) can appear BEFORE the field. Directional operators must mirror or results are + // silently inverted. These tests seed a real row because an empty table cannot distinguish + // `x < 3` from `x > 3`. + + @Nested + class OperandOrderSemantics { + + private ResourceEntity seeded() { + ResourceEntity r = new ResourceEntity("seed-1"); + r.setaBool(true); + r.setaString("seededString"); + r.setaNumber(5); + r.setOwnedBy(new java.util.ArrayList<>(java.util.List.of("user1"))); + r.addTag("tagX", "x"); + return r; + } + + @Test + void ltValueFirstMeansFieldGreaterThan() { + // 3 < aNumber, with aNumber = 5 → must match. The naive (unmirrored) translation + // `aNumber < 3` would return 0. + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("lt", nval(3), var("request.resource.attr.aNumber")))); + // Control: field-first form keeps its meaning. + assertEquals(0, runCount(exprOp("lt", var("request.resource.attr.aNumber"), nval(3)))); + }); + } + + @Test + void gtValueFirstMeansFieldLessThan() { + // 10 > aNumber, with aNumber = 5 → match. + withResource(seeded(), () -> + assertEquals(1, runCount(exprOp("gt", nval(10), var("request.resource.attr.aNumber"))))); + } + + @Test + void leGeValueFirstAreMirrored() { + withResource(seeded(), () -> { + // 5 <= aNumber → aNumber >= 5 → match + assertEquals(1, runCount(exprOp("le", nval(5), var("request.resource.attr.aNumber")))); + // 4 >= aNumber → aNumber <= 4 → no match + assertEquals(0, runCount(exprOp("ge", nval(4), var("request.resource.attr.aNumber")))); + }); + } + + @Test + void sizeValueFirstNonEmptyCheck() { + // 0 < size(ownedBy) → EXISTS; seeded row has one owner. + withResource(seeded(), () -> + assertEquals(1, runCount(exprOp("lt", + nval(0), + exprOp("size", var("request.resource.attr.ownedBy")))))); + } + + @Test + void sizeValueFirstEmptinessCheck() { + // 1 > size(ownedBy) → size < 1 → NOT EXISTS; seeded row is non-empty → 0. + withResource(seeded(), () -> + assertEquals(0, runCount(exprOp("gt", + nval(1), + exprOp("size", var("request.resource.attr.ownedBy")))))); + } + + @Test + void addFoldedConstantValueFirstIsMirrored() { + // (1 + 2) < aNumber → aNumber > 3, with aNumber = 5 → match. + withResource(seeded(), () -> + assertEquals(1, runCount(exprOp("lt", + exprOp("add", nval(1), nval(2)), + var("request.resource.attr.aNumber"))))); + } + + @Test + void hasIntersectionValueFirstIsSymmetric() { + // hasIntersection(["user1","other"], R.attr.ownedBy) — value list first. + withResource(seeded(), () -> + assertEquals(1, runCount(exprOp("hasIntersection", + listOp("user1", "other"), + var("request.resource.attr.ownedBy"))))); + } + + @Test + void hasIntersectionSnakeCaseAliasIsAccepted() { + // The PDP still accepts the deprecated has_intersection spelling in policies. + withResource(seeded(), () -> + assertEquals(1, runCount(exprOp("has_intersection", + var("request.resource.attr.ownedBy"), + listOp("user1"))))); + } + + @Test + void overrideIsConsultedUnderMirroredOperator() { + // 3 < aNumber builds a gt predicate — the override must be looked up as "gt". + Operand cond = exprOp("lt", nval(3), var("request.resource.attr.aNumber")); + assertThrows(OverrideInvoked.class, + () -> runCount(cond, Map.of("gt", THROWING_OVERRIDE))); + } + } + + // -- Lambda bodies referencing outer (non-lambda) resource attributes -- + + @Nested + class OuterReferencesInsideLambda { + + @Test + void outerAttributeInsideExistsLambda() { + // R.attr.tags.exists(t, t.name == "x" && R.attr.aBool) — the PDP keeps the residual + // R.attr.aBool INSIDE the lambda body; it must resolve against the correlated outer + // entity, not the joined tag. + ResourceEntity r = new ResourceEntity("seed-2"); + r.setaBool(true); + r.setaNumber(1); + r.setaString("s"); + r.addTag("tagX", "x"); + + withResource(r, () -> { + Operand cond = exprOp("exists", + var("request.resource.attr.tags"), + lambda("t", exprOp("and", + exprOp("eq", var("t.name"), sval("x")), + var("request.resource.attr.aBool")))); + assertEquals(1, runCount(cond)); + + // Same shape with a non-matching outer comparison → excluded. + Operand condNoMatch = exprOp("exists", + var("request.resource.attr.tags"), + lambda("t", exprOp("and", + exprOp("eq", var("t.name"), sval("x")), + exprOp("eq", var("request.resource.attr.aBool"), bval(false))))); + assertEquals(0, runCount(condNoMatch)); + }); + } + } + + // -- Malformed / hostile operand shapes -- + + @Test + void mapLambdaWithWrongArityThrowsCleanly() { + // A malformed lambda inside map() must produce IllegalArgumentException, not + // IndexOutOfBoundsException. + Operand mapExpr = exprOp("map", + var("request.resource.attr.tags"), + exprOp("lambda", var("t"))); + assertConditionThrows( + exprOp("hasIntersection", mapExpr, listOp("x")), + "map lambda requires exactly 2 operands"); + } + + @Test + void structValueWithNullEntryDoesNotThrow() { + // Struct fields may hold nulls; Collectors.toMap would NPE on them. + com.google.protobuf.Struct struct = com.google.protobuf.Struct.newBuilder() + .putFields("a", Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .putFields("b", Value.newBuilder().setStringValue("x").build()) + .build(); + Object converted = SpringDataQueryPlanAdapter.protoValueToJava( + Value.newBuilder().setStructValue(struct).build()); + assertInstanceOf(Map.class, converted); + Map map = (Map) converted; + assertEquals(2, map.size()); + assertNull(map.get("a")); + assertEquals("x", map.get("b")); + } + @Test void operatorOverrideIsUsed() { Operand cond = exprOp("eq", var("request.resource.attr.aString"), sval("foo")); From 7f7a3754798b858b4d2b3fda938f49b98018ec33 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Thu, 16 Jul 2026 09:51:20 +0100 Subject: [PATCH 10/20] refactor(spring-data): decompose translator; normalize operand order once Split the 1,351-line adapter into focused package-private modules with no public API change: - Scope.java: variable resolution, relation-chain walking, lambda scoping - HierarchyTranslator.java: overlaps/ancestorOf/descendentOf + models - PlanValues.java: proto<->Java conversion, add fold/solve, LIKE escaping - SpringDataQueryPlanAdapter.java (712 lines): public API + core Translator Structural cleanups, all behavior-preserving: - NormalizedBinary states the planner source-order invariant once (field-side first, directional ops mirrored on swap), replacing the scattered order fixes in the leaf handler, add comparison, size comparison, and hasIntersection - handleIn collapsed from two mirror-image branches into one flow; the mapping kind decides membership semantics, not operand order - correlate() extracted; the Root/Join correlation dance existed twice - RootScope.resolveMapping now derives from resolveRelationChain; duplicate walkRelationChain deleted - Scope.memberPath() canonicalizes the defaultMemberField fallback that lived in three places - dead Translator.topMapper field and constructor parameter removed Tests: runCount overloads deduplicated; integration attribute maps now derived via merge() instead of hand-duplicated entries. 203 tests pass in the Docker/testcontainers suite. Signed-off-by: Alex Olivier --- .../springdata/HierarchyTranslator.java | 307 +++++ .../queryplan/springdata/PlanValues.java | 102 ++ .../cerbos/queryplan/springdata/Scope.java | 224 ++++ .../SpringDataQueryPlanAdapter.java | 1005 +++-------------- .../springdata/SpringDataIntegrationTest.java | 68 +- .../SpringDataQueryPlanAdapterTest.java | 46 +- 6 files changed, 839 insertions(+), 913 deletions(-) create mode 100644 spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java create mode 100644 spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java create mode 100644 spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java new file mode 100644 index 00000000..f156ac87 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java @@ -0,0 +1,307 @@ +package dev.cerbos.queryplan.springdata; + +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression.Operand; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Translates the Cerbos hierarchy operators ({@code overlaps} / {@code ancestorOf} / + * {@code descendentOf}). + * + *

A Cerbos hierarchy is a delimited path (e.g. {@code "a:b:c"}). Both sides of a hierarchy + * operator are wrapped in a {@code hierarchy(...)} expression that resolves to one of: + *

    + *
  • a constant string split into segments,
  • + *
  • a single field whose column holds the whole delimited string, or
  • + *
  • a {@code list(...)} of segments, each a constant or a field.
  • + *
+ * The translations mirror the Prisma adapter so behaviour is consistent across adapters. + */ +final class HierarchyTranslator { + + private final CriteriaBuilder cb; + + HierarchyTranslator(CriteriaBuilder cb) { + this.cb = cb; + } + + /** A resolved {@code hierarchy(...)} operand: a constant path, a whole-column field, or a list of segments. */ + private sealed interface Hierarchy permits Hierarchy.Constant, Hierarchy.FieldRef, Hierarchy.Segmented { + /** A literal delimited path split into segments. */ + record Constant(List segments, String delimiter) implements Hierarchy {} + + /** A single column holding the whole delimited string. */ + record FieldRef(Path path, String delimiter) implements Hierarchy {} + + /** A {@code list(...)} of segments, each a constant or a field. */ + record Segmented(List segments) implements Hierarchy {} + } + + /** One segment of a {@link Hierarchy.Segmented}: either a literal value or a field reference. */ + private sealed interface Seg permits Seg.Const, Seg.FieldSeg { + record Const(String value) implements Seg {} + + record FieldSeg(Path path) implements Seg {} + } + + Predicate handleOverlaps(List operands, Scope scope) { + Hierarchy[] both = extractHierarchyOperands("overlaps", operands, scope); + Hierarchy left = both[0]; + Hierarchy right = both[1]; + + if (left instanceof Hierarchy.FieldRef || right instanceof Hierarchy.FieldRef) { + return handleFieldOverlaps(left, right); + } + + List leftSegs = toSegments(left); + List rightSegs = toSegments(right); + + List leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs); + List rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs); + + List> valid = new ArrayList<>(); + if (leftPrefixOfRight != null) valid.add(leftPrefixOfRight); + if (rightPrefixOfLeft != null) valid.add(rightPrefixOfLeft); + + if (valid.isEmpty()) { + // Neither side can be a prefix of the other. If a field is involved the overlap is + // simply never satisfiable (always-false); two incompatible constants are a planner bug. + boolean hasField = containsFieldSegment(leftSegs) || containsFieldSegment(rightSegs); + if (hasField) { + return cb.disjunction(); + } + throw new IllegalArgumentException("Cannot determine hierarchy overlap: no field references found"); + } + // An empty condition list means every compared segment was a matching constant — overlap + // holds unconditionally. + for (List c : valid) { + if (c.isEmpty()) { + return cb.conjunction(); + } + } + // Both directions (equal-length hierarchies) compare the same segment pairs, so either + // condition set is equivalent; use the first. + List chosen = valid.get(0); + return chosen.size() == 1 ? chosen.get(0) : cb.and(chosen.toArray(Predicate[]::new)); + } + + private Predicate handleFieldOverlaps(Hierarchy left, Hierarchy right) { + if (left instanceof Hierarchy.FieldRef && right instanceof Hierarchy.FieldRef) { + throw new IllegalArgumentException("overlaps: cannot compare two field-reference hierarchies"); + } + Hierarchy.FieldRef field = (left instanceof Hierarchy.FieldRef f) ? f : (Hierarchy.FieldRef) right; + Hierarchy other = (left instanceof Hierarchy.FieldRef) ? right : left; + if (!(other instanceof Hierarchy.Constant constant)) { + throw new IllegalArgumentException( + "overlaps: segmented hierarchies with field hierarchies are not supported"); + } + + String delimiter = field.delimiter(); + String otherRaw = String.join(delimiter, constant.segments()); + List strictPrefixes = getStrictPrefixes(constant.segments(), delimiter); + + List conditions = new ArrayList<>(); + // field is an ancestor of the constant... + if (!strictPrefixes.isEmpty()) { + conditions.add(field.path().in(strictPrefixes)); + } + // ...or equal to it... + conditions.add(cb.equal(field.path(), otherRaw)); + // ...or a descendant of it. + conditions.add(startsWithLiteral(field.path(), otherRaw + delimiter)); + + return conditions.size() == 1 ? conditions.get(0) : cb.or(conditions.toArray(Predicate[]::new)); + } + + Predicate handleAncestorDescendant(List operands, Scope scope, boolean isAncestor) { + String opName = isAncestor ? "ancestorOf" : "descendentOf"; + Hierarchy[] both = extractHierarchyOperands(opName, operands, scope); + // ancestorOf(A, B) ⇔ A is a strict prefix of B; descendentOf(A, B) ⇔ B is a strict prefix of A. + Hierarchy ancestor = isAncestor ? both[0] : both[1]; + Hierarchy descendant = isAncestor ? both[1] : both[0]; + + if (ancestor instanceof Hierarchy.Constant a && descendant instanceof Hierarchy.FieldRef d) { + String prefix = String.join(d.delimiter(), a.segments()) + d.delimiter(); + return startsWithLiteral(d.path(), prefix); + } + if (ancestor instanceof Hierarchy.FieldRef a && descendant instanceof Hierarchy.Constant d) { + List prefixes = getStrictPrefixes(d.segments(), a.delimiter()); + if (prefixes.isEmpty()) { + return cb.disjunction(); + } + if (prefixes.size() == 1) { + return cb.equal(a.path(), prefixes.get(0)); + } + return a.path().in(prefixes); + } + if (ancestor instanceof Hierarchy.Constant a && descendant instanceof Hierarchy.Constant d) { + if (d.segments().size() > a.segments().size() + && isPrefix(a.segments(), d.segments())) { + return cb.conjunction(); + } + throw new IllegalArgumentException( + opName + ": constant operands do not satisfy the " + (isAncestor ? "ancestor" : "descendant") + + " relationship"); + } + throw new IllegalArgumentException(opName + ": unsupported hierarchy operand combination"); + } + + private Hierarchy[] extractHierarchyOperands(String opName, List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException(opName + " requires exactly 2 operands"); + } + return new Hierarchy[]{ + normalizeHierarchy(resolveHierarchy(opName, operands.get(0), scope)), + normalizeHierarchy(resolveHierarchy(opName, operands.get(1), scope)), + }; + } + + private Hierarchy resolveHierarchy(String opName, Operand operand, Scope scope) { + if (operand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"hierarchy".equals(operand.getExpression().getOperator())) { + throw new IllegalArgumentException(opName + " requires hierarchy(...) operands"); + } + List ops = operand.getExpression().getOperandsList(); + if (ops.size() == 2) { + Operand strOp = ops.get(0); + Operand delimOp = ops.get(1); + if (delimOp.getNodeCase() != Operand.NodeCase.VALUE) { + throw new IllegalArgumentException("hierarchy delimiter must be a value"); + } + String delimiter = String.valueOf(PlanValues.protoValueToJava(delimOp.getValue())); + if (strOp.getNodeCase() == Operand.NodeCase.VALUE) { + String raw = String.valueOf(PlanValues.protoValueToJava(strOp.getValue())); + return new Hierarchy.Constant(splitLiteral(raw, delimiter), delimiter); + } + if (strOp.getNodeCase() == Operand.NodeCase.VARIABLE) { + return new Hierarchy.FieldRef(scope.resolvePath(strOp.getVariable()), delimiter); + } + throw new IllegalArgumentException("hierarchy(string, delimiter) requires a value or field operand"); + } + if (ops.size() == 1) { + Operand inner = ops.get(0); + return switch (inner.getNodeCase()) { + case VALUE -> new Hierarchy.Constant( + splitLiteral(String.valueOf(PlanValues.protoValueToJava(inner.getValue())), "."), "."); + case VARIABLE -> new Hierarchy.FieldRef(scope.resolvePath(inner.getVariable()), "."); + case EXPRESSION -> { + if (!"list".equals(inner.getExpression().getOperator())) { + throw new IllegalArgumentException("hierarchy requires a value, field, or list operand"); + } + List segs = new ArrayList<>(); + for (Operand seg : inner.getExpression().getOperandsList()) { + switch (seg.getNodeCase()) { + case VALUE -> segs.add(new Seg.Const( + String.valueOf(PlanValues.protoValueToJava(seg.getValue())))); + case VARIABLE -> segs.add(new Seg.FieldSeg(scope.resolvePath(seg.getVariable()))); + default -> throw new IllegalArgumentException( + "hierarchy list segment must be a value or field, got " + seg.getNodeCase()); + } + } + yield new Hierarchy.Segmented(segs); + } + default -> throw new IllegalArgumentException( + "hierarchy requires a value, field, or list operand, got " + inner.getNodeCase()); + }; + } + throw new IllegalArgumentException("hierarchy requires 1 or 2 operands"); + } + + /** Collapse an all-constant segmented hierarchy to a plain Constant (default delimiter). */ + private Hierarchy normalizeHierarchy(Hierarchy h) { + if (!(h instanceof Hierarchy.Segmented seg)) { + return h; + } + List values = new ArrayList<>(); + for (Seg s : seg.segments()) { + if (s instanceof Seg.Const c) { + values.add(c.value()); + } else { + return h; + } + } + return new Hierarchy.Constant(values, "."); + } + + private List toSegments(Hierarchy h) { + if (h instanceof Hierarchy.Constant c) { + return c.segments().stream().map(s -> (Seg) new Seg.Const(s)).toList(); + } + if (h instanceof Hierarchy.Segmented s) { + return s.segments(); + } + throw new IllegalArgumentException("Cannot enumerate segments of a field-reference hierarchy"); + } + + /** + * If {@code shorter} is a prefix of {@code longer}, return the predicates that must hold for + * the field segments to line up (an empty list = unconditionally true). Returns {@code null} + * if {@code shorter} cannot be a prefix of {@code longer}. + */ + private List checkPrefixConditions(List shorter, List longer) { + if (shorter.size() > longer.size()) { + return null; + } + List conditions = new ArrayList<>(); + for (int i = 0; i < shorter.size(); i++) { + Seg s = shorter.get(i); + Seg l = longer.get(i); + if (s instanceof Seg.Const sc && l instanceof Seg.Const lc) { + if (!sc.value().equals(lc.value())) { + return null; + } + } else if (s instanceof Seg.FieldSeg sf && l instanceof Seg.Const lc) { + conditions.add(cb.equal(sf.path(), lc.value())); + } else if (s instanceof Seg.Const sc && l instanceof Seg.FieldSeg lf) { + conditions.add(cb.equal(lf.path(), sc.value())); + } else { + throw new IllegalArgumentException( + "Cannot compare two field references in a hierarchy overlap"); + } + } + return conditions; + } + + private Predicate startsWithLiteral(Path path, String prefix) { + return cb.like(path.as(String.class), PlanValues.escapeLike(prefix) + "%", '\\'); + } + + private static boolean containsFieldSegment(List segs) { + return segs.stream().anyMatch(s -> s instanceof Seg.FieldSeg); + } + + private static boolean isPrefix(List shorter, List longer) { + for (int i = 0; i < shorter.size(); i++) { + if (!shorter.get(i).equals(longer.get(i))) { + return false; + } + } + return true; + } + + /** All proper (strict) ancestor prefixes of a segment list, joined with {@code delimiter}. */ + private static List getStrictPrefixes(List segments, String delimiter) { + if (segments.size() <= 1) { + return List.of(); + } + List prefixes = new ArrayList<>(); + String current = segments.get(0); + prefixes.add(current); + for (int i = 1; i < segments.size() - 1; i++) { + current = current + delimiter + segments.get(i); + prefixes.add(current); + } + return prefixes; + } + + /** Split on a literal delimiter (not a regex), keeping trailing empty segments. */ + private static List splitLiteral(String raw, String delimiter) { + return List.of(raw.split(Pattern.quote(delimiter), -1)); + } +} diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java new file mode 100644 index 00000000..f586f602 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java @@ -0,0 +1,102 @@ +package dev.cerbos.queryplan.springdata; + +import com.google.protobuf.Value; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Conversions between protobuf plan values and plain Java values, plus the constant-folding + * helpers for the {@code add} operator and shared SQL literal escaping. + */ +final class PlanValues { + + private PlanValues() {} + + static Object protoValueToJava(Value value) { + return switch (value.getKindCase()) { + case STRING_VALUE -> value.getStringValue(); + case NUMBER_VALUE -> { + double d = value.getNumberValue(); + if (d == Math.floor(d) && !Double.isInfinite(d)) { + yield (long) d; + } + yield d; + } + case BOOL_VALUE -> value.getBoolValue(); + case NULL_VALUE -> null; + case LIST_VALUE -> value.getListValue().getValuesList().stream() + .map(PlanValues::protoValueToJava) + .toList(); + case STRUCT_VALUE -> { + // Not Collectors.toMap: it rejects null values, and struct fields may hold nulls. + Map struct = new LinkedHashMap<>(); + value.getStructValue().getFieldsMap() + .forEach((k, v) -> struct.put(k, protoValueToJava(v))); + yield struct; + } + case KIND_NOT_SET -> throw new IllegalArgumentException( + "Protobuf Value has no kind set — the planner emitted a malformed operand"); + default -> throw new IllegalArgumentException( + "Unsupported protobuf value type: " + value.getKindCase()); + }; + } + + /** + * Fold {@code add(left, right)} where both operands are constants. Strings concatenate; + * numbers add. Used when the planner emits e.g. {@code eq(field, add("prefix:", "123"))}. + */ + static Object foldAdd(Object left, Object right) { + if (left == null || right == null) { + // Reaching here means the planner emitted `add(null, ...)` or `add(..., null)` + // — neither side could satisfy any string/number equation, so report the shape + // explicitly rather than NPE'ing on `.getClass()` below. + throw new IllegalArgumentException( + "add requires non-null operands, got " + left + " + " + right); + } + if (left instanceof String || right instanceof String) { + return String.valueOf(left) + String.valueOf(right); + } + if (left instanceof Number ln && right instanceof Number rn) { + if (left instanceof Long && right instanceof Long) { + return ln.longValue() + rn.longValue(); + } + return ln.doubleValue() + rn.doubleValue(); + } + throw new IllegalArgumentException( + "add requires string or numeric operands, got " + left.getClass() + " + " + right.getClass()); + } + + /** + * Solve {@code field + addConstant == comparisonValue} (or with operands swapped if + * {@code !fieldIsLeft}). For strings: strip the prefix/suffix and return what the field must + * equal; return {@code null} if the comparison value doesn't match the constant's + * shape (which means no field value can satisfy the equation). For numbers: subtract. + */ + static Object solveAdd(Object comparisonValue, Object addConstant, boolean fieldIsLeft) { + if (comparisonValue instanceof String compStr && addConstant instanceof String constStr) { + if (fieldIsLeft) { + // field + const == comparison → field == comparison stripped-of-suffix + if (!compStr.endsWith(constStr)) return null; + return compStr.substring(0, compStr.length() - constStr.length()); + } + // const + field == comparison → field == comparison stripped-of-prefix + if (!compStr.startsWith(constStr)) return null; + return compStr.substring(constStr.length()); + } + if (comparisonValue instanceof Number compNum && addConstant instanceof Number constNum) { + // Both orderings of numeric addition produce the same equation: field = comp - const + if (comparisonValue instanceof Long && addConstant instanceof Long) { + return compNum.longValue() - constNum.longValue(); + } + return compNum.doubleValue() - constNum.doubleValue(); + } + throw new IllegalArgumentException( + "add comparison type mismatch: " + comparisonValue.getClass() + " vs " + addConstant.getClass()); + } + + /** Escape {@code LIKE} wildcards; pair with an explicit {@code '\\'} escape character. */ + static String escapeLike(String s) { + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } +} diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java new file mode 100644 index 00000000..b780c590 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java @@ -0,0 +1,224 @@ +package dev.cerbos.queryplan.springdata; + +import jakarta.persistence.criteria.AbstractQuery; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Path; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * Resolution context for Cerbos plan variables: maps a variable such as + * {@code request.resource.attr.foo} (or a lambda-scoped {@code t.name}) to a JPA {@link Path} + * or an {@link AttributeMapping}, relative to the {@code From} the current (sub)query is built + * against. + */ +sealed interface Scope permits Scope.RootScope, Scope.LambdaScope { + + Path resolvePath(String cerbosVar); + + AttributeMapping resolveMapping(String cerbosVar); + + From from(); + + AbstractQuery parentQuery(); + + static Scope root(From root, AbstractQuery query, Map mapper) { + return new RootScope(root, query, mapper); + } + + static Scope lambda(From from, AbstractQuery parentQuery, + AttributeMapping.Relation relation, String lambdaVar, Scope outer) { + return new LambdaScope(from, parentQuery, relation, lambdaVar, outer); + } + + /** + * Re-root {@code scope} at the correlated copy of its {@code from} inside a subquery, so + * paths resolved through it become valid correlation references of that subquery. + */ + static Scope rebase(Scope scope, From correlated, AbstractQuery sub) { + if (scope instanceof RootScope rs) { + return new RootScope(correlated, sub, rs.mapper()); + } + LambdaScope ls = (LambdaScope) scope; + return new LambdaScope(correlated, sub, ls.relation(), ls.lambdaVar(), ls.outer()); + } + + record RootScope(From from, AbstractQuery parentQuery, Map mapper) + implements Scope { + @Override + public Path resolvePath(String cerbosVar) { + AttributeMapping m = mapper.get(cerbosVar); + if (m == null) { + throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); + } + if (m instanceof AttributeMapping.Field f) { + return traversePath(from, f.jpaPath()); + } + throw new IllegalArgumentException( + "Attribute " + cerbosVar + " is a Relation; cannot resolve as a scalar path"); + } + + @Override + public AttributeMapping resolveMapping(String cerbosVar) { + AttributeMapping m = mapper.get(cerbosVar); + if (m != null) { + return m; + } + // Try resolving as a dotted suffix off a registered Relation prefix. + // Example: mapper has "request.resource.attr.categories" → Relation("categories", fields={"subCategories": Relation(...)}) + // and we're asked for "request.resource.attr.categories.subCategories" — walk the chain. + RelationChain chain = resolveRelationChain(mapper, cerbosVar); + if (chain != null) { + return chain.tail() != null + ? chain.tail() + : chain.relations().get(chain.relations().size() - 1); + } + throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); + } + } + + /** + * Scope inside a collection lambda. Variables prefixed with the lambda variable resolve + * against the joined collection element; anything else (e.g. another + * {@code request.resource.attr.*} reference in the lambda body) delegates to {@code outer} + * — the enclosing scope re-rooted at the subquery's correlated parent, so the produced + * path is a legal correlation reference. + */ + record LambdaScope(From from, AbstractQuery parentQuery, + AttributeMapping.Relation relation, String lambdaVar, + Scope outer) implements Scope { + + private boolean isLambdaRef(String cerbosVar) { + return cerbosVar.equals(lambdaVar) || cerbosVar.startsWith(lambdaVar + "."); + } + + @Override + public Path resolvePath(String cerbosVar) { + if (!isLambdaRef(cerbosVar)) { + if (outer != null) { + return outer.resolvePath(cerbosVar); + } + throw new IllegalArgumentException( + "Variable '" + cerbosVar + "' does not start with lambda variable '" + lambdaVar + "'"); + } + return memberPath(from, relation, extractLambdaSuffix(cerbosVar, lambdaVar)); + } + + @Override + public AttributeMapping resolveMapping(String cerbosVar) { + if (!isLambdaRef(cerbosVar)) { + if (outer != null) { + return outer.resolveMapping(cerbosVar); + } + throw new IllegalArgumentException( + "Variable '" + cerbosVar + "' does not start with lambda variable '" + lambdaVar + "'"); + } + String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); + if (suffix.isEmpty()) { + return relation; + } + AttributeMapping nested = relation.fields().get(suffix); + if (nested != null) { + return nested; + } + return AttributeMapping.field(suffix); + } + } + + /** + * A dotted top-level Cerbos attribute resolved to a chain of Relations, ending in either a + * leaf {@code tail} Field or (when {@code tail} is null) the final Relation itself. + */ + record RelationChain(List relations, AttributeMapping.Field tail) {} + + /** + * Resolve a Cerbos variable to a {@link RelationChain} by matching the longest registered + * Relation prefix and walking the remaining dotted suffix through nested {@code fields()} + * maps. Returns {@code null} if no prefix resolves all the way. + */ + static RelationChain resolveRelationChain(Map mapper, String cerbosVar) { + AttributeMapping direct = mapper.get(cerbosVar); + if (direct instanceof AttributeMapping.Relation rel) { + return new RelationChain(List.of(rel), null); + } + String[] parts = cerbosVar.split("\\."); + for (int i = parts.length - 1; i > 0; i--) { + String prefix = String.join(".", Arrays.copyOfRange(parts, 0, i)); + if (!(mapper.get(prefix) instanceof AttributeMapping.Relation rel)) { + continue; + } + String[] suffixParts = Arrays.copyOfRange(parts, i, parts.length); + List chain = new ArrayList<>(); + chain.add(rel); + AttributeMapping current = rel; + boolean ok = true; + for (int s = 0; s < suffixParts.length; s++) { + if (!(current instanceof AttributeMapping.Relation r)) { + ok = false; + break; + } + AttributeMapping next = r.fields().get(suffixParts[s]); + if (next == null) { + ok = false; + break; + } + if (next instanceof AttributeMapping.Relation nextRel) { + chain.add(nextRel); + current = nextRel; + } else if (next instanceof AttributeMapping.Field leafField && s == suffixParts.length - 1) { + return new RelationChain(chain, leafField); + } else { + ok = false; + break; + } + } + if (ok) { + return new RelationChain(chain, null); + } + } + return null; + } + + /** + * Resolve a member path off a join over {@code rel}: an empty/null {@code memberField} + * yields the relation's {@code defaultMemberField} if set, else the joined element itself + * ({@code @ElementCollection} of primitives); otherwise the member resolves through the + * relation's {@code fields()} mapping, falling back to the raw name as a JPA path. + */ + static Path memberPath(From from, AttributeMapping.Relation rel, String memberField) { + if (memberField == null || memberField.isEmpty()) { + if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { + return from.get(rel.defaultMemberField()); + } + return (Path) from; + } + AttributeMapping nested = rel.fields().get(memberField); + if (nested instanceof AttributeMapping.Field f) { + return traversePath(from, f.jpaPath()); + } + return traversePath(from, memberField); + } + + static Path traversePath(From from, String dottedJpaPath) { + Path p = from; + for (String part : dottedJpaPath.split("\\.")) { + p = p.get(part); + } + return p; + } + + static String extractLambdaSuffix(String variable, String lambdaVar) { + if (variable.equals(lambdaVar)) { + return ""; + } + String prefix = lambdaVar + "."; + if (!variable.startsWith(prefix)) { + throw new IllegalArgumentException( + "Variable '" + variable + "' does not start with lambda variable '" + lambdaVar + "'"); + } + return variable.substring(prefix.length()); + } +} diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 5eeb4f0f..e4405aea 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -1,14 +1,11 @@ package dev.cerbos.queryplan.springdata; -import com.google.protobuf.Value; import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter; import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression.Operand; import dev.cerbos.api.v1.response.Response.PlanResourcesResponse; import dev.cerbos.sdk.PlanResourcesResult; -import jakarta.persistence.criteria.AbstractQuery; import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.Path; @@ -48,7 +45,7 @@ public static Result toSpecification( Operand condition = planResult.getCondition() .orElseThrow(() -> new IllegalArgumentException("Conditional plan has no condition")); return new Result.Conditional<>((root, query, cb) -> - new Translator(cb, mapper, overrides).traverse(condition, Scope.root(root, query, mapper))); + new Translator(cb, overrides).traverse(condition, Scope.root(root, query, mapper))); } // -- PlanResourcesResponse overloads -- @@ -72,7 +69,7 @@ public static Result toSpecification( throw new IllegalArgumentException("Conditional plan has no condition"); } yield new Result.Conditional((root, query, cb) -> - new Translator(cb, mapper, overrides).traverse(cond, Scope.root(root, query, mapper))); + new Translator(cb, overrides).traverse(cond, Scope.root(root, query, mapper))); } default -> throw new IllegalArgumentException("Unknown filter kind: " + filter.getKind()); }; @@ -82,15 +79,13 @@ public static Result toSpecification( private static final class Translator { private final CriteriaBuilder cb; - private final Map topMapper; private final Map overrides; + private final HierarchyTranslator hierarchy; - Translator(CriteriaBuilder cb, - Map topMapper, - Map overrides) { + Translator(CriteriaBuilder cb, Map overrides) { this.cb = cb; - this.topMapper = topMapper; this.overrides = overrides; + this.hierarchy = new HierarchyTranslator(cb); } Predicate traverse(Operand operand, Scope scope) { @@ -127,50 +122,71 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, case "hasIntersection", "has_intersection" -> handleHasIntersection(operands, scope); case "isSet" -> handleIsSet(operands, scope); case "in" -> handleIn(operands, scope); - case "overlaps" -> handleOverlaps(operands, scope); - case "ancestorOf" -> handleAncestorDescendant(operands, scope, true); - case "descendentOf" -> handleAncestorDescendant(operands, scope, false); + case "overlaps" -> hierarchy.handleOverlaps(operands, scope); + case "ancestorOf" -> hierarchy.handleAncestorDescendant(operands, scope, true); + case "descendentOf" -> hierarchy.handleAncestorDescendant(operands, scope, false); default -> { - Predicate sizePred = trySizeComparison(op, operands, scope); + NormalizedBinary nb = NormalizedBinary.of(op, operands); + Predicate sizePred = trySizeComparison(nb.op(), nb.operands(), scope); if (sizePred != null) { yield sizePred; } - yield handleLeafOperator(op, operands, scope); + yield handleLeafOperator(nb.op(), nb.operands(), scope); } }; } /** - * Mirror a directional comparison operator, for normalizing value-first operand order. - * The planner preserves policy source order, so {@code 5 < R.attr.x} arrives as - * {@code lt(value(5), variable(x))} — which must translate to {@code x > 5}, not - * {@code x < 5}. Symmetric operators (eq/ne/...) are returned unchanged. + * A binary expression normalized to field-side-first. The planner preserves policy source + * order, so a constant may precede the field it constrains ({@code 5 < R.attr.x} arrives + * as {@code lt(value(5), variable(x))}). Normalizing once here — most field-like operand + * first (variable > nested expression > constant value), mirroring directional operators + * when swapping — lets every downstream handler assume field-first order. A consequence + * is that {@link OperatorFunction} overrides are consulted under the mirrored operator: + * a value-first {@code lt} is looked up as {@code gt}. */ - private static String mirrorOperator(String op) { - return switch (op) { - case "lt" -> "gt"; - case "gt" -> "lt"; - case "le" -> "ge"; - case "ge" -> "le"; - default -> op; - }; + private record NormalizedBinary(String op, List operands) { + + static NormalizedBinary of(String op, List operands) { + if (operands.size() == 2 && rank(operands.get(0)) < rank(operands.get(1))) { + return new NormalizedBinary(mirror(op), List.of(operands.get(1), operands.get(0))); + } + return new NormalizedBinary(op, operands); + } + + private static int rank(Operand o) { + return switch (o.getNodeCase()) { + case VARIABLE -> 2; + case EXPRESSION -> 1; + default -> 0; + }; + } + + /** lt/le/gt/ge mirror when their operands swap sides; symmetric operators are unchanged. */ + private static String mirror(String op) { + return switch (op) { + case "lt" -> "gt"; + case "gt" -> "lt"; + case "le" -> "ge"; + case "ge" -> "le"; + default -> op; + }; + } } // -- Leaf operators (eq/ne/lt/gt/le/ge/contains/startsWith/endsWith) -- + /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ private Predicate handleLeafOperator(String op, List operands, Scope scope) { // Detect leaf comparisons where one side is an 'add' expression (e.g. string // concatenation: `aString == "prefix:" + R.attr.id`). We fold constants and solve for // the field side when possible — same algorithm as the Prisma adapter. Operand addExprOperand = null; Operand otherOperand = null; - boolean addIsFirst = false; - for (int i = 0; i < operands.size(); i++) { - Operand o = operands.get(i); + for (Operand o : operands) { if (o.getNodeCase() == Operand.NodeCase.EXPRESSION && "add".equals(o.getExpression().getOperator())) { addExprOperand = o; - addIsFirst = i == 0; } else { otherOperand = o; } @@ -179,16 +195,13 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc if (otherOperand == null) { throw new IllegalArgumentException("add comparison requires a second operand"); } - return handleAddComparison(op, addExprOperand.getExpression(), otherOperand, addIsFirst, scope); + return handleAddComparison(op, addExprOperand.getExpression(), otherOperand, scope); } String variable = null; - int variableIndex = -1; Object value = null; - int valueIndex = -1; boolean valueSeen = false; - for (int i = 0; i < operands.size(); i++) { - Operand o = operands.get(i); + for (Operand o : operands) { switch (o.getNodeCase()) { case VARIABLE -> { if (variable != null) { @@ -200,11 +213,9 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc + op + "': " + variable + " vs " + o.getVariable()); } variable = o.getVariable(); - variableIndex = i; } case VALUE -> { - value = protoValueToJava(o.getValue()); - valueIndex = i; + value = PlanValues.protoValueToJava(o.getValue()); valueSeen = true; } case EXPRESSION -> { @@ -231,11 +242,6 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc if (!valueSeen) { throw new IllegalArgumentException("Missing value operand for " + op); } - // Value-first comparisons (`5 < R.attr.x`) must mirror the operator so the predicate - // is built field-first with equivalent semantics (`x > 5`). - if (valueIndex < variableIndex) { - op = mirrorOperator(op); - } Path path = scope.resolvePath(variable); @@ -279,21 +285,21 @@ private Predicate defaultLeaf(String op, Path path, Object value) { case "gt" -> cb.greaterThan(raw, (Comparable) value); case "le" -> cb.lessThanOrEqualTo(raw, (Comparable) value); case "ge" -> cb.greaterThanOrEqualTo(raw, (Comparable) value); - case "contains" -> cb.like(path.as(String.class), "%" + escapeLike(String.valueOf(value)) + "%", '\\'); - case "startsWith" -> cb.like(path.as(String.class), escapeLike(String.valueOf(value)) + "%", '\\'); - case "endsWith" -> cb.like(path.as(String.class), "%" + escapeLike(String.valueOf(value)), '\\'); + case "contains" -> cb.like(path.as(String.class), + "%" + PlanValues.escapeLike(String.valueOf(value)) + "%", '\\'); + case "startsWith" -> cb.like(path.as(String.class), + PlanValues.escapeLike(String.valueOf(value)) + "%", '\\'); + case "endsWith" -> cb.like(path.as(String.class), + "%" + PlanValues.escapeLike(String.valueOf(value)), '\\'); default -> throw new IllegalArgumentException("Unsupported operator: " + op); }; } - private static String escapeLike(String s) { - return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); - } - // -- add (fold + solve for string concat / numeric translation) -- + /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression addExpr, - Operand otherOperand, boolean addIsFirst, Scope scope) { + Operand otherOperand, Scope scope) { List addOperands = addExpr.getOperandsList(); if (addOperands.size() != 2) { throw new IllegalArgumentException("add requires exactly 2 operands"); @@ -302,18 +308,19 @@ private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression Operand addRight = addOperands.get(1); // Case 1: add(value, value) — fold the two constants, then compare to the field. + // Normalization guarantees the field variable sits on the left of the comparison, + // so the folded constant compares as `field op folded`. if (addLeft.getNodeCase() == Operand.NodeCase.VALUE && addRight.getNodeCase() == Operand.NodeCase.VALUE) { - Object folded = foldAdd( - protoValueToJava(addLeft.getValue()), - protoValueToJava(addRight.getValue())); + Object folded = PlanValues.foldAdd( + PlanValues.protoValueToJava(addLeft.getValue()), + PlanValues.protoValueToJava(addRight.getValue())); if (otherOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { throw new IllegalArgumentException( "add(const, const) compared to a non-field operand is not supported"); } Path path = scope.resolvePath(otherOperand.getVariable()); - // `add(1, 2) < R.attr.x` means `3 < x` — mirror to build the predicate field-first. - return applyLeaf(addIsFirst ? mirrorOperator(op) : op, path, folded); + return applyLeaf(op, path, folded); } // Case 2: add(field, value) or add(value, field) — solve for the field. @@ -327,7 +334,7 @@ private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression throw new IllegalArgumentException( "add(field, value) requires a value on the other side of the comparison"); } - Object otherValue = protoValueToJava(otherOperand.getValue()); + Object otherValue = PlanValues.protoValueToJava(otherOperand.getValue()); Operand fieldOp; Object addConst; @@ -335,19 +342,19 @@ private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression if (addLeft.getNodeCase() == Operand.NodeCase.VARIABLE && addRight.getNodeCase() == Operand.NodeCase.VALUE) { fieldOp = addLeft; - addConst = protoValueToJava(addRight.getValue()); + addConst = PlanValues.protoValueToJava(addRight.getValue()); fieldIsLeft = true; } else if (addLeft.getNodeCase() == Operand.NodeCase.VALUE && addRight.getNodeCase() == Operand.NodeCase.VARIABLE) { fieldOp = addRight; - addConst = protoValueToJava(addLeft.getValue()); + addConst = PlanValues.protoValueToJava(addLeft.getValue()); fieldIsLeft = false; } else { throw new IllegalArgumentException( "add requires exactly one field reference and one value, or two values"); } - Object solved = solveAdd(otherValue, addConst, fieldIsLeft); + Object solved = PlanValues.solveAdd(otherValue, addConst, fieldIsLeft); if (solved == null) { // No solution exists (e.g. "projects:123" == "users:" + R.id can never be true). // eq → always-false; ne → always-true. @@ -368,7 +375,7 @@ private Predicate handleIsSet(List operands, Scope scope) { for (Operand o : operands) { if (o.getNodeCase() == Operand.NodeCase.VARIABLE) variable = o.getVariable(); else if (o.getNodeCase() == Operand.NodeCase.VALUE) { - Object v = protoValueToJava(o.getValue()); + Object v = PlanValues.protoValueToJava(o.getValue()); if (!(v instanceof Boolean b)) { throw new IllegalArgumentException("isSet second operand must be a boolean"); } @@ -388,81 +395,61 @@ else if (o.getNodeCase() == Operand.NodeCase.VALUE) { // -- in (set membership or collection membership) -- - private Predicate handleIn(List operands, Scope scope) { - if (operands.size() != 2) { + private Predicate handleIn(List rawOperands, Scope scope) { + if (rawOperands.size() != 2) { throw new IllegalArgumentException("in requires exactly 2 operands"); } - Operand left = operands.get(0); - Operand right = operands.get(1); - - if (left.getNodeCase() == Operand.NodeCase.VARIABLE - && right.getNodeCase() == Operand.NodeCase.VALUE) { - String var = left.getVariable(); - Object val = protoValueToJava(right.getValue()); - - AttributeMapping mapping = scope.resolveMapping(var); - if (mapping instanceof AttributeMapping.Relation rel) { - List values = (val instanceof List l) ? l : List.of(val); - return collectionContainsAny(scope, rel, values); - } - - Path path = scope.resolvePath(var); - OperatorFunction override = overrides.get("in"); - if (override != null) { - return override.apply(cb, path, val); - } - if (val instanceof List list) { - if (list.isEmpty()) { - return cb.disjunction(); - } - return path.in(list); - } - return cb.equal(path, val); + // Both shapes — `field in [values]` and `value in collection-field` — resolve the + // same way once normalized field-first: the mapping kind (Relation vs Field) decides + // whether this is collection membership or a scalar IN, not the operand order. + List operands = NormalizedBinary.of("in", rawOperands).operands(); + Operand fieldOp = operands.get(0); + Operand valueOp = operands.get(1); + if (fieldOp.getNodeCase() != Operand.NodeCase.VARIABLE + || valueOp.getNodeCase() != Operand.NodeCase.VALUE) { + throw new IllegalArgumentException("Unsupported in operand combination: " + + rawOperands.get(0).getNodeCase() + "/" + rawOperands.get(1).getNodeCase()); } + String var = fieldOp.getVariable(); + Object val = PlanValues.protoValueToJava(valueOp.getValue()); - if (left.getNodeCase() == Operand.NodeCase.VALUE - && right.getNodeCase() == Operand.NodeCase.VARIABLE) { - Object val = protoValueToJava(left.getValue()); - String var = right.getVariable(); + AttributeMapping mapping = scope.resolveMapping(var); + if (mapping instanceof AttributeMapping.Relation rel) { + List values = (val instanceof List l) ? l : List.of(val); + return collectionContainsAny(scope, rel, values); + } - AttributeMapping mapping = scope.resolveMapping(var); - if (mapping instanceof AttributeMapping.Relation rel) { - return collectionContainsAny(scope, rel, List.of(val)); - } - Path path = scope.resolvePath(var); - OperatorFunction override = overrides.get("in"); - if (override != null) { - return override.apply(cb, path, val); + Path path = scope.resolvePath(var); + OperatorFunction override = overrides.get("in"); + if (override != null) { + return override.apply(cb, path, val); + } + if (val instanceof List list) { + if (list.isEmpty()) { + return cb.disjunction(); } - return cb.equal(path, val); + return path.in(list); } - - throw new IllegalArgumentException( - "Unsupported in operand combination: " + left.getNodeCase() + "/" + right.getNodeCase()); + return cb.equal(path, val); } // -- hasIntersection -- - private Predicate handleHasIntersection(List operands, Scope scope) { - if (operands.size() != 2) { + private Predicate handleHasIntersection(List rawOperands, Scope scope) { + if (rawOperands.size() != 2) { throw new IllegalArgumentException("hasIntersection requires exactly 2 operands"); } - Operand first = operands.get(0); - Operand second = operands.get(1); // Intersection is symmetric, and the planner preserves policy source order — // `hasIntersection(P.attr.tags, R.attr.tags)` folds the principal side to a value - // list in the FIRST position. Normalize to field/map-first. - if (first.getNodeCase() == Operand.NodeCase.VALUE - && second.getNodeCase() != Operand.NodeCase.VALUE) { - Operand tmp = first; - first = second; - second = tmp; - } + // list in the FIRST position. Normalization puts the field/map side first. + List operands = NormalizedBinary.of("hasIntersection", rawOperands).operands(); + Operand first = operands.get(0); + Operand second = operands.get(1); if (first.getNodeCase() == Operand.NodeCase.VARIABLE && second.getNodeCase() == Operand.NodeCase.VALUE) { String var = first.getVariable(); - Object val = protoValueToJava(second.getValue()); + Object val = PlanValues.protoValueToJava(second.getValue()); List values = (val instanceof List l) ? l : List.of(val); AttributeMapping mapping = scope.resolveMapping(var); @@ -483,71 +470,71 @@ private Predicate handleHasIntersection(List operands, Scope scope) { throw new IllegalArgumentException( "hasIntersection second operand must be a value list when used with map()"); } - Object val = protoValueToJava(second.getValue()); + Object val = PlanValues.protoValueToJava(second.getValue()); List values = (val instanceof List l) ? l : List.of(val); - // hasIntersection(map(...), []) is always false; short-circuit before the subquery. - if (values.isEmpty()) { - return cb.disjunction(); - } + return handleMapIntersection(first.getExpression(), values, scope); + } - PlanResourcesFilter.Expression mapExpr = first.getExpression(); - List mapOperands = mapExpr.getOperandsList(); - if (mapOperands.size() != 2) { - throw new IllegalArgumentException("map requires exactly 2 operands"); - } - Operand collectionOperand = mapOperands.get(0); - Operand lambdaOperand = mapOperands.get(1); + throw new IllegalArgumentException( + "Unsupported hasIntersection operand shape: " + first.getNodeCase()); + } - if (collectionOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { - throw new IllegalArgumentException("map first operand must be a variable"); - } - if (lambdaOperand.getNodeCase() != Operand.NodeCase.EXPRESSION - || !"lambda".equals(lambdaOperand.getExpression().getOperator())) { - throw new IllegalArgumentException("map second operand must be a lambda"); - } + /** Translate {@code hasIntersection(map(collection, lambda), values)}. */ + private Predicate handleMapIntersection(PlanResourcesFilter.Expression mapExpr, + List values, Scope scope) { + // hasIntersection(map(...), []) is always false; short-circuit before the subquery. + if (values.isEmpty()) { + return cb.disjunction(); + } - String collectionVar = collectionOperand.getVariable(); + List mapOperands = mapExpr.getOperandsList(); + if (mapOperands.size() != 2) { + throw new IllegalArgumentException("map requires exactly 2 operands"); + } + Operand collectionOperand = mapOperands.get(0); + Operand lambdaOperand = mapOperands.get(1); - PlanResourcesFilter.Expression lambdaExpr = lambdaOperand.getExpression(); - List lambdaOps = lambdaExpr.getOperandsList(); - if (lambdaOps.size() != 2) { - throw new IllegalArgumentException("map lambda requires exactly 2 operands (body, variable)"); - } - Operand projection = lambdaOps.get(0); - Operand lambdaVar = lambdaOps.get(1); - if (projection.getNodeCase() != Operand.NodeCase.VARIABLE - || lambdaVar.getNodeCase() != Operand.NodeCase.VARIABLE) { - throw new IllegalArgumentException("map lambda body must be a simple variable projection"); - } - String memberField = extractLambdaSuffix(projection.getVariable(), lambdaVar.getVariable()); - - // Check whether the collection path resolves through one Relation or a chain. - // A chain (e.g. "request.resource.attr.categories.subCategories") emits nested - // EXISTS subqueries — one per hop. - if (scope instanceof Scope.RootScope rootScope) { - RelationChain chain = resolveRelationChain(rootScope.mapper(), collectionVar); - if (chain != null && !chain.relations().isEmpty()) { - AttributeMapping.Relation tailRel = chain.relations().get(chain.relations().size() - 1); - return chainedExistsSubquery(scope, chain.relations(), (sub, joinFrom, correlated) -> { - Path field = resolveMemberPath(joinFrom, tailRel, memberField); - return field.in(values); - }); - } - } + if (collectionOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("map first operand must be a variable"); + } + if (lambdaOperand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"lambda".equals(lambdaOperand.getExpression().getOperator())) { + throw new IllegalArgumentException("map second operand must be a lambda"); + } - AttributeMapping mapping = scope.resolveMapping(collectionVar); - if (mapping instanceof AttributeMapping.Relation rel) { - return existsSubquery(scope, rel, (sub, joinFrom, correlated) -> { - Path field = resolveMemberPath(joinFrom, rel, memberField); - return field.in(values); - }); + String collectionVar = collectionOperand.getVariable(); + + List lambdaOps = lambdaOperand.getExpression().getOperandsList(); + if (lambdaOps.size() != 2) { + throw new IllegalArgumentException("map lambda requires exactly 2 operands (body, variable)"); + } + Operand projection = lambdaOps.get(0); + Operand lambdaVar = lambdaOps.get(1); + if (projection.getNodeCase() != Operand.NodeCase.VARIABLE + || lambdaVar.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("map lambda body must be a simple variable projection"); + } + String memberField = Scope.extractLambdaSuffix(projection.getVariable(), lambdaVar.getVariable()); + + // Check whether the collection path resolves through one Relation or a chain. + // A chain (e.g. "request.resource.attr.categories.subCategories") emits nested + // EXISTS subqueries — one per hop. + if (scope instanceof Scope.RootScope rootScope) { + Scope.RelationChain chain = Scope.resolveRelationChain(rootScope.mapper(), collectionVar); + if (chain != null && !chain.relations().isEmpty()) { + AttributeMapping.Relation tailRel = chain.relations().get(chain.relations().size() - 1); + return chainedExistsSubquery(scope, chain.relations(), (sub, joinFrom, correlated) -> + Scope.memberPath(joinFrom, tailRel, memberField).in(values)); } - throw new IllegalArgumentException( - "map can only be applied to a collection mapped as Relation: " + collectionVar); } + AttributeMapping mapping = scope.resolveMapping(collectionVar); + if (mapping instanceof AttributeMapping.Relation rel) { + return existsSubquery(scope, rel, (sub, joinFrom, correlated) -> + Scope.memberPath(joinFrom, rel, memberField).in(values)); + } throw new IllegalArgumentException( - "Unsupported hasIntersection operand shape: " + first.getNodeCase()); + "map can only be applied to a collection mapped as Relation: " + collectionVar); } private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relation rel, List values) { @@ -557,13 +544,7 @@ private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relat return cb.disjunction(); } return existsSubquery(outerScope, rel, (sub, joinFrom, correlated) -> { - Path field; - if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { - field = joinFrom.get(rel.defaultMemberField()); - } else { - // @ElementCollection - the join itself is the element value - field = (Path) joinFrom; - } + Path field = Scope.memberPath(joinFrom, rel, null); if (values.size() == 1) { return cb.equal(field, values.get(0)); } @@ -573,29 +554,22 @@ private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relat // -- size(collection) N -- + /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ private Predicate trySizeComparison(String op, List operands, Scope scope) { PlanResourcesFilter.Expression sizeExpr = null; - boolean sizeIsFirst = false; Long numValue = null; - for (int i = 0; i < operands.size(); i++) { - Operand o = operands.get(i); + for (Operand o : operands) { if (o.getNodeCase() == Operand.NodeCase.EXPRESSION && "size".equals(o.getExpression().getOperator())) { sizeExpr = o.getExpression(); - sizeIsFirst = i == 0; } else if (o.getNodeCase() == Operand.NodeCase.VALUE) { - Object v = protoValueToJava(o.getValue()); + Object v = PlanValues.protoValueToJava(o.getValue()); if (v instanceof Number n) numValue = n.longValue(); } } if (sizeExpr == null || numValue == null) { return null; } - // `0 < size(x)` arrives as lt(value(0), size(x)); mirror so the checks below can - // always assume the size() expression is on the left. - if (!sizeIsFirst) { - op = mirrorOperator(op); - } List sizeOps = sizeExpr.getOperandsList(); if (sizeOps.size() != 1 || sizeOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE) { throw new IllegalArgumentException("Unsupported size() expression"); @@ -624,7 +598,6 @@ private Predicate trySizeComparison(String op, List operands, Scope sco // -- exists / exists_one / all / except / filter -- - @SuppressWarnings("unchecked") private Predicate handleCollectionOperator(String op, List operands, Scope scope) { if (operands.size() != 2) { throw new IllegalArgumentException(op + " requires exactly 2 operands"); @@ -647,8 +620,7 @@ private Predicate handleCollectionOperator(String op, List operands, Sc op + " requires a Relation mapping for " + collectionVar); } - PlanResourcesFilter.Expression lambdaExpr = lambdaOperand.getExpression(); - List lambdaOps = lambdaExpr.getOperandsList(); + List lambdaOps = lambdaOperand.getExpression().getOperandsList(); if (lambdaOps.size() != 2) { throw new IllegalArgumentException("lambda requires exactly 2 operands"); } @@ -662,28 +634,20 @@ private Predicate handleCollectionOperator(String op, List operands, Sc return switch (op) { case "exists", "filter" -> existsSubquery(scope, rel, (sub, joinFrom, correlated) -> traverse(body, - Scope.lambda(joinFrom, sub, rel, lambdaVarName, rebase(scope, correlated, sub)))); + Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub)))); case "except" -> existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.not(traverse(body, - Scope.lambda(joinFrom, sub, rel, lambdaVarName, rebase(scope, correlated, sub))))); + Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub))))); case "all" -> cb.not(existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.not(traverse(body, - Scope.lambda(joinFrom, sub, rel, lambdaVarName, rebase(scope, correlated, sub)))))); + Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub)))))); case "exists_one" -> { Subquery sub = scope.parentQuery().subquery(Long.class); - From outerFrom = scope.from(); - From correlated; - if (outerFrom instanceof Root r) { - correlated = sub.correlate(r); - } else if (outerFrom instanceof Join j) { - correlated = sub.correlate((Join) j); - } else { - throw new IllegalArgumentException("Cannot correlate scope: " + outerFrom); - } + From correlated = correlate(sub, scope.from()); Join joinFrom = correlated.join(rel.joinAttribute()); sub.select(cb.count(joinFrom)); sub.where(traverse(body, - Scope.lambda(joinFrom, sub, rel, lambdaVarName, rebase(scope, correlated, sub)))); + Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub)))); yield cb.equal(sub, 1L); } default -> throw new IllegalArgumentException("Unsupported collection operator: " + op); @@ -702,16 +666,16 @@ private interface SubqueryBodyBuilder { Predicate build(Subquery sub, From joinFrom, From correlated); } - /** - * Re-root {@code scope} at the correlated copy of its {@code from} inside a subquery, so - * paths resolved through it become valid correlation references of that subquery. - */ - private static Scope rebase(Scope scope, From correlated, Subquery sub) { - if (scope instanceof Scope.RootScope rs) { - return new Scope.RootScope(correlated, sub, rs.mapper()); + /** Correlate the current scope's {@code From} into {@code sub}. */ + @SuppressWarnings("unchecked") + private static From correlate(Subquery sub, From outerFrom) { + if (outerFrom instanceof Root r) { + return sub.correlate(r); + } + if (outerFrom instanceof Join j) { + return sub.correlate((Join) j); } - Scope.LambdaScope ls = (Scope.LambdaScope) scope; - return new Scope.LambdaScope(correlated, sub, ls.relation(), ls.lambdaVar(), ls.outer()); + throw new IllegalArgumentException("Cannot correlate from non-Root, non-Join scope: " + outerFrom); } /** @@ -720,7 +684,7 @@ private static Scope rebase(Scope scope, From correlated, Subquery sub) * The {@code bodyBuilder} produces the leaf predicate against the innermost join. */ private Predicate chainedExistsSubquery(Scope scope, - java.util.List chain, + List chain, SubqueryBodyBuilder bodyBuilder) { if (chain.size() == 1) { return existsSubquery(scope, chain.get(0), bodyBuilder); @@ -731,621 +695,18 @@ private Predicate chainedExistsSubquery(Scope scope, // character, so this sentinel can never collide with a user-supplied lambda name. AttributeMapping.Relation thisRel = chain.get(0); Scope intermediate = Scope.lambda(joinFrom, sub, thisRel, "$$chain$$", - rebase(scope, correlated, sub)); + Scope.rebase(scope, correlated, sub)); return chainedExistsSubquery(intermediate, chain.subList(1, chain.size()), bodyBuilder); }); } - @SuppressWarnings("unchecked") private Predicate existsSubquery(Scope scope, AttributeMapping.Relation rel, SubqueryBodyBuilder bodyBuilder) { - From outerFrom = scope.from(); Subquery sub = scope.parentQuery().subquery(Integer.class); - From correlated; - if (outerFrom instanceof Root r) { - correlated = sub.correlate(r); - } else if (outerFrom instanceof Join j) { - correlated = sub.correlate((Join) j); - } else { - throw new IllegalArgumentException("Cannot correlate from non-Root, non-Join scope: " + outerFrom); - } + From correlated = correlate(sub, scope.from()); Join joinFrom = correlated.join(rel.joinAttribute()); sub.select(cb.literal(1)); - Predicate body = bodyBuilder.build(sub, joinFrom, correlated); - sub.where(body); + sub.where(bodyBuilder.build(sub, joinFrom, correlated)); return cb.exists(sub); } - - // -- hierarchy operators (overlaps / ancestorOf / descendentOf) -- - // - // A Cerbos hierarchy is a delimited path (e.g. "a:b:c"). Both sides of a hierarchy - // operator are wrapped in a `hierarchy(...)` expression that resolves to one of: - // - a constant string split into segments, - // - a single field whose column holds the whole delimited string, or - // - a `list(...)` of segments, each a constant or a field. - // The translations mirror the Prisma adapter so behaviour is consistent across adapters. - - private Predicate handleOverlaps(List operands, Scope scope) { - Hierarchy[] both = extractHierarchyOperands("overlaps", operands, scope); - Hierarchy left = both[0]; - Hierarchy right = both[1]; - - if (left instanceof Hierarchy.FieldRef || right instanceof Hierarchy.FieldRef) { - return handleFieldOverlaps(left, right); - } - - List leftSegs = toSegments(left); - List rightSegs = toSegments(right); - - List leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs); - List rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs); - - java.util.List> valid = new java.util.ArrayList<>(); - if (leftPrefixOfRight != null) valid.add(leftPrefixOfRight); - if (rightPrefixOfLeft != null) valid.add(rightPrefixOfLeft); - - if (valid.isEmpty()) { - // Neither side can be a prefix of the other. If a field is involved the overlap is - // simply never satisfiable (always-false); two incompatible constants are a planner bug. - boolean hasField = containsFieldSegment(leftSegs) || containsFieldSegment(rightSegs); - if (hasField) { - return cb.disjunction(); - } - throw new IllegalArgumentException("Cannot determine hierarchy overlap: no field references found"); - } - // An empty condition list means every compared segment was a matching constant — overlap - // holds unconditionally. - for (List c : valid) { - if (c.isEmpty()) { - return cb.conjunction(); - } - } - // Both directions (equal-length hierarchies) compare the same segment pairs, so either - // condition set is equivalent; use the first. - List chosen = valid.get(0); - return chosen.size() == 1 ? chosen.get(0) : cb.and(chosen.toArray(Predicate[]::new)); - } - - private Predicate handleFieldOverlaps(Hierarchy left, Hierarchy right) { - if (left instanceof Hierarchy.FieldRef && right instanceof Hierarchy.FieldRef) { - throw new IllegalArgumentException("overlaps: cannot compare two field-reference hierarchies"); - } - Hierarchy.FieldRef field = (left instanceof Hierarchy.FieldRef f) ? f : (Hierarchy.FieldRef) right; - Hierarchy other = (left instanceof Hierarchy.FieldRef) ? right : left; - if (!(other instanceof Hierarchy.Constant constant)) { - throw new IllegalArgumentException( - "overlaps: segmented hierarchies with field hierarchies are not supported"); - } - - String delimiter = field.delimiter(); - String otherRaw = String.join(delimiter, constant.segments()); - List strictPrefixes = getStrictPrefixes(constant.segments(), delimiter); - - java.util.List conditions = new java.util.ArrayList<>(); - // field is an ancestor of the constant... - if (!strictPrefixes.isEmpty()) { - conditions.add(field.path().in(strictPrefixes)); - } - // ...or equal to it... - conditions.add(cb.equal(field.path(), otherRaw)); - // ...or a descendant of it. - conditions.add(startsWithLiteral(field.path(), otherRaw + delimiter)); - - return conditions.size() == 1 ? conditions.get(0) : cb.or(conditions.toArray(Predicate[]::new)); - } - - private Predicate handleAncestorDescendant(List operands, Scope scope, boolean isAncestor) { - String opName = isAncestor ? "ancestorOf" : "descendentOf"; - Hierarchy[] both = extractHierarchyOperands(opName, operands, scope); - // ancestorOf(A, B) ⇔ A is a strict prefix of B; descendentOf(A, B) ⇔ B is a strict prefix of A. - Hierarchy ancestor = isAncestor ? both[0] : both[1]; - Hierarchy descendant = isAncestor ? both[1] : both[0]; - - if (ancestor instanceof Hierarchy.Constant a && descendant instanceof Hierarchy.FieldRef d) { - String prefix = String.join(d.delimiter(), a.segments()) + d.delimiter(); - return startsWithLiteral(d.path(), prefix); - } - if (ancestor instanceof Hierarchy.FieldRef a && descendant instanceof Hierarchy.Constant d) { - List prefixes = getStrictPrefixes(d.segments(), a.delimiter()); - if (prefixes.isEmpty()) { - return cb.disjunction(); - } - if (prefixes.size() == 1) { - return cb.equal(a.path(), prefixes.get(0)); - } - return a.path().in(prefixes); - } - if (ancestor instanceof Hierarchy.Constant a && descendant instanceof Hierarchy.Constant d) { - if (d.segments().size() > a.segments().size() - && isPrefix(a.segments(), d.segments())) { - return cb.conjunction(); - } - throw new IllegalArgumentException( - opName + ": constant operands do not satisfy the " + (isAncestor ? "ancestor" : "descendant") - + " relationship"); - } - throw new IllegalArgumentException(opName + ": unsupported hierarchy operand combination"); - } - - private Hierarchy[] extractHierarchyOperands(String opName, List operands, Scope scope) { - if (operands.size() != 2) { - throw new IllegalArgumentException(opName + " requires exactly 2 operands"); - } - return new Hierarchy[]{ - normalizeHierarchy(resolveHierarchy(opName, operands.get(0), scope)), - normalizeHierarchy(resolveHierarchy(opName, operands.get(1), scope)), - }; - } - - private Hierarchy resolveHierarchy(String opName, Operand operand, Scope scope) { - if (operand.getNodeCase() != Operand.NodeCase.EXPRESSION - || !"hierarchy".equals(operand.getExpression().getOperator())) { - throw new IllegalArgumentException(opName + " requires hierarchy(...) operands"); - } - List ops = operand.getExpression().getOperandsList(); - if (ops.size() == 2) { - Operand strOp = ops.get(0); - Operand delimOp = ops.get(1); - if (delimOp.getNodeCase() != Operand.NodeCase.VALUE) { - throw new IllegalArgumentException("hierarchy delimiter must be a value"); - } - String delimiter = String.valueOf(protoValueToJava(delimOp.getValue())); - if (strOp.getNodeCase() == Operand.NodeCase.VALUE) { - String raw = String.valueOf(protoValueToJava(strOp.getValue())); - return new Hierarchy.Constant(splitLiteral(raw, delimiter), delimiter); - } - if (strOp.getNodeCase() == Operand.NodeCase.VARIABLE) { - return new Hierarchy.FieldRef(scope.resolvePath(strOp.getVariable()), delimiter); - } - throw new IllegalArgumentException("hierarchy(string, delimiter) requires a value or field operand"); - } - if (ops.size() == 1) { - Operand inner = ops.get(0); - return switch (inner.getNodeCase()) { - case VALUE -> new Hierarchy.Constant( - splitLiteral(String.valueOf(protoValueToJava(inner.getValue())), "."), "."); - case VARIABLE -> new Hierarchy.FieldRef(scope.resolvePath(inner.getVariable()), "."); - case EXPRESSION -> { - if (!"list".equals(inner.getExpression().getOperator())) { - throw new IllegalArgumentException("hierarchy requires a value, field, or list operand"); - } - java.util.List segs = new java.util.ArrayList<>(); - for (Operand seg : inner.getExpression().getOperandsList()) { - switch (seg.getNodeCase()) { - case VALUE -> segs.add(new Seg.Const(String.valueOf(protoValueToJava(seg.getValue())))); - case VARIABLE -> segs.add(new Seg.FieldSeg(scope.resolvePath(seg.getVariable()))); - default -> throw new IllegalArgumentException( - "hierarchy list segment must be a value or field, got " + seg.getNodeCase()); - } - } - yield new Hierarchy.Segmented(segs); - } - default -> throw new IllegalArgumentException( - "hierarchy requires a value, field, or list operand, got " + inner.getNodeCase()); - }; - } - throw new IllegalArgumentException("hierarchy requires 1 or 2 operands"); - } - - /** Collapse an all-constant segmented hierarchy to a plain Constant (default delimiter). */ - private Hierarchy normalizeHierarchy(Hierarchy h) { - if (!(h instanceof Hierarchy.Segmented seg)) { - return h; - } - java.util.List values = new java.util.ArrayList<>(); - for (Seg s : seg.segments()) { - if (s instanceof Seg.Const c) { - values.add(c.value()); - } else { - return h; - } - } - return new Hierarchy.Constant(values, "."); - } - - private List toSegments(Hierarchy h) { - if (h instanceof Hierarchy.Constant c) { - return c.segments().stream().map(s -> (Seg) new Seg.Const(s)).toList(); - } - if (h instanceof Hierarchy.Segmented s) { - return s.segments(); - } - throw new IllegalArgumentException("Cannot enumerate segments of a field-reference hierarchy"); - } - - /** - * If {@code shorter} is a prefix of {@code longer}, return the predicates that must hold for - * the field segments to line up (an empty list = unconditionally true). Returns {@code null} - * if {@code shorter} cannot be a prefix of {@code longer}. - */ - private List checkPrefixConditions(List shorter, List longer) { - if (shorter.size() > longer.size()) { - return null; - } - java.util.List conditions = new java.util.ArrayList<>(); - for (int i = 0; i < shorter.size(); i++) { - Seg s = shorter.get(i); - Seg l = longer.get(i); - if (s instanceof Seg.Const sc && l instanceof Seg.Const lc) { - if (!sc.value().equals(lc.value())) { - return null; - } - } else if (s instanceof Seg.FieldSeg sf && l instanceof Seg.Const lc) { - conditions.add(cb.equal(sf.path(), lc.value())); - } else if (s instanceof Seg.Const sc && l instanceof Seg.FieldSeg lf) { - conditions.add(cb.equal(lf.path(), sc.value())); - } else { - throw new IllegalArgumentException( - "Cannot compare two field references in a hierarchy overlap"); - } - } - return conditions; - } - - private Predicate startsWithLiteral(Path path, String prefix) { - return cb.like(path.as(String.class), escapeLike(prefix) + "%", '\\'); - } - - private static boolean containsFieldSegment(List segs) { - return segs.stream().anyMatch(s -> s instanceof Seg.FieldSeg); - } - - private static boolean isPrefix(List shorter, List longer) { - for (int i = 0; i < shorter.size(); i++) { - if (!shorter.get(i).equals(longer.get(i))) { - return false; - } - } - return true; - } - - /** All proper (strict) ancestor prefixes of a segment list, joined with {@code delimiter}. */ - private static List getStrictPrefixes(List segments, String delimiter) { - if (segments.size() <= 1) { - return List.of(); - } - java.util.List prefixes = new java.util.ArrayList<>(); - String current = segments.get(0); - prefixes.add(current); - for (int i = 1; i < segments.size() - 1; i++) { - current = current + delimiter + segments.get(i); - prefixes.add(current); - } - return prefixes; - } - - /** Split on a literal delimiter (not a regex), keeping trailing empty segments. */ - private static List splitLiteral(String raw, String delimiter) { - return List.of(raw.split(java.util.regex.Pattern.quote(delimiter), -1)); - } - } - - // -- Scope -- - - private sealed interface Scope permits Scope.RootScope, Scope.LambdaScope { - Path resolvePath(String cerbosVar); - - AttributeMapping resolveMapping(String cerbosVar); - - From from(); - - AbstractQuery parentQuery(); - - static Scope root(From root, AbstractQuery query, Map mapper) { - return new RootScope(root, query, mapper); - } - - static Scope lambda(From from, AbstractQuery parentQuery, - AttributeMapping.Relation relation, String lambdaVar, Scope outer) { - return new LambdaScope(from, parentQuery, relation, lambdaVar, outer); - } - - record RootScope(From from, AbstractQuery parentQuery, Map mapper) - implements Scope { - @Override - public Path resolvePath(String cerbosVar) { - AttributeMapping m = mapper.get(cerbosVar); - if (m == null) { - throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); - } - if (m instanceof AttributeMapping.Field f) { - return traversePath(from, f.jpaPath()); - } - throw new IllegalArgumentException( - "Attribute " + cerbosVar + " is a Relation; cannot resolve as a scalar path"); - } - - @Override - public AttributeMapping resolveMapping(String cerbosVar) { - AttributeMapping m = mapper.get(cerbosVar); - if (m != null) { - return m; - } - - // Try resolving as a dotted suffix off a registered Relation prefix. - // Example: mapper has "request.resource.attr.categories" → Relation("categories", fields={"subCategories": Relation(...)}) - // and we're asked for "request.resource.attr.categories.subCategories" — walk the chain. - String[] parts = cerbosVar.split("\\."); - for (int i = parts.length - 1; i > 0; i--) { - String prefix = String.join(".", java.util.Arrays.copyOfRange(parts, 0, i)); - AttributeMapping prefixMapping = mapper.get(prefix); - if (prefixMapping instanceof AttributeMapping.Relation rel) { - AttributeMapping resolved = walkRelationChain(rel, - java.util.Arrays.copyOfRange(parts, i, parts.length)); - if (resolved != null) { - return resolved; - } - } - } - - throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); - } - } - - /** - * Scope inside a collection lambda. Variables prefixed with the lambda variable resolve - * against the joined collection element; anything else (e.g. another - * {@code request.resource.attr.*} reference in the lambda body) delegates to {@code outer} - * — the enclosing scope re-rooted at the subquery's correlated parent, so the produced - * path is a legal correlation reference. - */ - record LambdaScope(From from, AbstractQuery parentQuery, - AttributeMapping.Relation relation, String lambdaVar, - Scope outer) implements Scope { - - private boolean isLambdaRef(String cerbosVar) { - return cerbosVar.equals(lambdaVar) || cerbosVar.startsWith(lambdaVar + "."); - } - - @Override - public Path resolvePath(String cerbosVar) { - if (!isLambdaRef(cerbosVar)) { - if (outer != null) { - return outer.resolvePath(cerbosVar); - } - throw new IllegalArgumentException( - "Variable '" + cerbosVar + "' does not start with lambda variable '" + lambdaVar + "'"); - } - String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); - if (suffix.isEmpty()) { - if (relation.defaultMemberField() != null && !relation.defaultMemberField().isEmpty()) { - return from.get(relation.defaultMemberField()); - } - return (Path) from; - } - AttributeMapping nested = relation.fields().get(suffix); - if (nested instanceof AttributeMapping.Field f) { - return traversePath(from, f.jpaPath()); - } - return traversePath(from, suffix); - } - - @Override - public AttributeMapping resolveMapping(String cerbosVar) { - if (!isLambdaRef(cerbosVar)) { - if (outer != null) { - return outer.resolveMapping(cerbosVar); - } - throw new IllegalArgumentException( - "Variable '" + cerbosVar + "' does not start with lambda variable '" + lambdaVar + "'"); - } - String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); - if (suffix.isEmpty()) { - return relation; - } - AttributeMapping nested = relation.fields().get(suffix); - if (nested != null) { - return nested; - } - return AttributeMapping.field(suffix); - } - } - } - - // -- helpers -- - - /** - * Fold {@code add(left, right)} where both operands are constants. Strings concatenate; - * numbers add. Used when the planner emits e.g. {@code eq(field, add("prefix:", "123"))}. - */ - static Object foldAdd(Object left, Object right) { - if (left == null || right == null) { - // Reaching here means the planner emitted `add(null, ...)` or `add(..., null)` - // — neither side could satisfy any string/number equation, so report the shape - // explicitly rather than NPE'ing on `.getClass()` below. - throw new IllegalArgumentException( - "add requires non-null operands, got " + left + " + " + right); - } - if (left instanceof String || right instanceof String) { - return String.valueOf(left) + String.valueOf(right); - } - if (left instanceof Number ln && right instanceof Number rn) { - if (left instanceof Long && right instanceof Long) { - return ln.longValue() + rn.longValue(); - } - return ln.doubleValue() + rn.doubleValue(); - } - throw new IllegalArgumentException( - "add requires string or numeric operands, got " + left.getClass() + " + " + right.getClass()); - } - - /** - * Solve {@code field + addConstant == comparisonValue} (or with operands swapped if - * {@code !fieldIsLeft}). For strings: strip the prefix/suffix and return what the field must - * equal; return {@code null} if the comparison value doesn't match the constant's - * shape (which means no field value can satisfy the equation). For numbers: subtract. - */ - static Object solveAdd(Object comparisonValue, Object addConstant, boolean fieldIsLeft) { - if (comparisonValue instanceof String compStr && addConstant instanceof String constStr) { - if (fieldIsLeft) { - // field + const == comparison → field == comparison stripped-of-suffix - if (!compStr.endsWith(constStr)) return null; - return compStr.substring(0, compStr.length() - constStr.length()); - } - // const + field == comparison → field == comparison stripped-of-prefix - if (!compStr.startsWith(constStr)) return null; - return compStr.substring(constStr.length()); - } - if (comparisonValue instanceof Number compNum && addConstant instanceof Number constNum) { - // Both orderings of numeric addition produce the same equation: field = comp - const - if (comparisonValue instanceof Long && addConstant instanceof Long) { - return compNum.longValue() - constNum.longValue(); - } - return compNum.doubleValue() - constNum.doubleValue(); - } - throw new IllegalArgumentException( - "add comparison type mismatch: " + comparisonValue.getClass() + " vs " + addConstant.getClass()); - } - - /** - * Walk a dotted suffix through a Relation's nested {@code fields()} map. Returns the leaf - * mapping (Field or Relation) reached, or {@code null} if any segment doesn't resolve. - */ - private static AttributeMapping walkRelationChain(AttributeMapping.Relation rel, String[] suffixParts) { - AttributeMapping current = rel; - for (String part : suffixParts) { - if (!(current instanceof AttributeMapping.Relation r)) { - return null; - } - AttributeMapping next = r.fields().get(part); - if (next == null) { - return null; - } - current = next; - } - return current; - } - - /** - * Resolve a dotted top-level Cerbos attribute to a chain of Relations, ending in either a - * leaf Field or the final Relation. Used by {@code hasIntersection(map(...))} when the map's - * collection operand is a dotted path through nested Relation mappings. - */ - record RelationChain(List relations, AttributeMapping.Field tail) {} - - /** A resolved {@code hierarchy(...)} operand: a constant path, a whole-column field, or a list of segments. */ - private sealed interface Hierarchy permits Hierarchy.Constant, Hierarchy.FieldRef, Hierarchy.Segmented { - /** A literal delimited path split into segments. */ - record Constant(List segments, String delimiter) implements Hierarchy {} - - /** A single column holding the whole delimited string. */ - record FieldRef(Path path, String delimiter) implements Hierarchy {} - - /** A {@code list(...)} of segments, each a constant or a field. */ - record Segmented(List segments) implements Hierarchy {} - } - - /** One segment of a {@link Hierarchy.Segmented}: either a literal value or a field reference. */ - private sealed interface Seg permits Seg.Const, Seg.FieldSeg { - record Const(String value) implements Seg {} - - record FieldSeg(Path path) implements Seg {} - } - - private static RelationChain resolveRelationChain(Map mapper, String cerbosVar) { - AttributeMapping direct = mapper.get(cerbosVar); - if (direct instanceof AttributeMapping.Relation rel) { - return new RelationChain(List.of(rel), null); - } - String[] parts = cerbosVar.split("\\."); - for (int i = parts.length - 1; i > 0; i--) { - String prefix = String.join(".", java.util.Arrays.copyOfRange(parts, 0, i)); - AttributeMapping prefixMapping = mapper.get(prefix); - if (!(prefixMapping instanceof AttributeMapping.Relation rel)) { - continue; - } - String[] suffixParts = java.util.Arrays.copyOfRange(parts, i, parts.length); - java.util.List chain = new java.util.ArrayList<>(); - chain.add(rel); - AttributeMapping current = rel; - boolean ok = true; - for (int s = 0; s < suffixParts.length; s++) { - if (!(current instanceof AttributeMapping.Relation r)) { - ok = false; - break; - } - AttributeMapping next = r.fields().get(suffixParts[s]); - if (next == null) { - ok = false; - break; - } - if (next instanceof AttributeMapping.Relation nextRel) { - chain.add(nextRel); - current = nextRel; - } else if (next instanceof AttributeMapping.Field leafField && s == suffixParts.length - 1) { - return new RelationChain(chain, leafField); - } else { - ok = false; - break; - } - } - if (ok) { - return new RelationChain(chain, null); - } - } - return null; - } - - private static Path resolveMemberPath(From joinFrom, AttributeMapping.Relation rel, String memberField) { - if (memberField == null || memberField.isEmpty()) { - if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { - return joinFrom.get(rel.defaultMemberField()); - } - return (Path) joinFrom; - } - AttributeMapping nested = rel.fields().get(memberField); - if (nested instanceof AttributeMapping.Field f) { - return traversePath(joinFrom, f.jpaPath()); - } - return traversePath(joinFrom, memberField); - } - - private static Path traversePath(From from, String dottedJpaPath) { - String[] parts = dottedJpaPath.split("\\."); - Path p = from; - for (String part : parts) { - p = p.get(part); - } - return p; - } - - private static String extractLambdaSuffix(String variable, String lambdaVar) { - if (variable.equals(lambdaVar)) { - return ""; - } - String prefix = lambdaVar + "."; - if (!variable.startsWith(prefix)) { - throw new IllegalArgumentException( - "Variable '" + variable + "' does not start with lambda variable '" + lambdaVar + "'"); - } - return variable.substring(prefix.length()); - } - - static Object protoValueToJava(Value value) { - return switch (value.getKindCase()) { - case STRING_VALUE -> value.getStringValue(); - case NUMBER_VALUE -> { - double d = value.getNumberValue(); - if (d == Math.floor(d) && !Double.isInfinite(d)) { - yield (long) d; - } - yield d; - } - case BOOL_VALUE -> value.getBoolValue(); - case NULL_VALUE -> null; - case LIST_VALUE -> value.getListValue().getValuesList().stream() - .map(SpringDataQueryPlanAdapter::protoValueToJava) - .toList(); - case STRUCT_VALUE -> { - // Not Collectors.toMap: it rejects null values, and struct fields may hold nulls. - Map struct = new java.util.LinkedHashMap<>(); - value.getStructValue().getFieldsMap() - .forEach((k, v) -> struct.put(k, protoValueToJava(v))); - yield struct; - } - case KIND_NOT_SET -> throw new IllegalArgumentException( - "Protobuf Value has no kind set — the planner emitted a malformed operand"); - default -> throw new IllegalArgumentException( - "Unsupported protobuf value type: " + value.getKindCase()); - }; } } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 050085c7..44518498 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -86,40 +86,13 @@ class SpringDataIntegrationTest { Map.entry("request.resource.attr.nested.nextlevel.aString", AttributeMapping.field("nested.nextlevel.aString")) ); - // Combined map used by tests that reference both nested.* and categories (e.g. combined-or) - // or the full kitchen-sink (tags + nested + tagObjects + ...). - private static final Map COMBINED_MAP; - static { - java.util.HashMap m = new java.util.HashMap<>(); - m.put("request.resource.attr.aBool", AttributeMapping.field("aBool")); - m.put("request.resource.attr.aString", AttributeMapping.field("aString")); - m.put("request.resource.attr.aNumber", AttributeMapping.field("aNumber")); - m.put("request.resource.attr.id", AttributeMapping.field("oid")); - m.put("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")); - m.put("request.resource.attr.createdBy", AttributeMapping.field("createdBy")); - m.put("request.resource.attr.ownedBy", AttributeMapping.relation("ownedBy")); - // tags as the @OneToMany TagEntity collection (for exists/all/filter etc.) - m.put("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( - "id", AttributeMapping.field("id"), - "name", AttributeMapping.field("name") - ))); - m.put("request.resource.attr.nested.aBool", AttributeMapping.field("nested.aBool")); - m.put("request.resource.attr.nested.aString", AttributeMapping.field("nested.aString")); - m.put("request.resource.attr.nested.aNumber", AttributeMapping.field("nested.aNumber")); - m.put("request.resource.attr.nested.aOptionalString", AttributeMapping.field("nested.aOptionalString")); - m.put("request.resource.attr.nested.nextlevel.aBool", AttributeMapping.field("nested.nextlevel.aBool")); - m.put("request.resource.attr.nested.nextlevel.aString", AttributeMapping.field("nested.nextlevel.aString")); - m.put("request.resource.attr.categories", AttributeMapping.relation("categories", Map.of( - "name", AttributeMapping.field("name"), - "subCategories", AttributeMapping.relation("subCategories", Map.of( - "name", AttributeMapping.field("name"), - "labels", AttributeMapping.relation("labels", Map.of( - "name", AttributeMapping.field("name") - )) - )) - ))); - COMBINED_MAP = Map.copyOf(m); - } + // FIELD_MAP, but with tags as the @OneToMany TagEntity collection (for exists/all/filter etc.) + // instead of the flat tagNames element collection. + private static final Map NESTED_FIELD_MAP = merge(FIELD_MAP, + Map.of("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "id", AttributeMapping.field("id"), + "name", AttributeMapping.field("name") + )))); private static final Map CATEGORIES_MAP = Map.ofEntries( Map.entry("request.resource.attr.categories", AttributeMapping.relation("categories", Map.of( @@ -133,22 +106,17 @@ class SpringDataIntegrationTest { ))) ); - private static final Map NESTED_FIELD_MAP = Map.ofEntries( - Map.entry("request.resource.attr.aBool", AttributeMapping.field("aBool")), - Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), - Map.entry("request.resource.attr.aNumber", AttributeMapping.field("aNumber")), - Map.entry("request.resource.attr.id", AttributeMapping.field("oid")), - Map.entry("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")), - Map.entry("request.resource.attr.createdBy", AttributeMapping.field("createdBy")), - Map.entry("request.resource.attr.ownedBy", AttributeMapping.relation("ownedBy")), - Map.entry("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( - "id", AttributeMapping.field("id"), - "name", AttributeMapping.field("name") - ))), - Map.entry("request.resource.attr.nested.aBool", AttributeMapping.field("nested.aBool")), - Map.entry("request.resource.attr.nested.aString", AttributeMapping.field("nested.aString")), - Map.entry("request.resource.attr.nested.aNumber", AttributeMapping.field("nested.aNumber")) - ); + // Kitchen-sink map for tests that mix nested.*, TagEntity tags, and categories (combined-or, + // kitchensink, principal-attribute actions). + private static final Map COMBINED_MAP = merge(NESTED_FIELD_MAP, CATEGORIES_MAP); + + /** Right-biased union of attribute maps. */ + private static Map merge(Map base, + Map overlay) { + java.util.HashMap m = new java.util.HashMap<>(base); + m.putAll(overlay); + return Map.copyOf(m); + } /** Records every SQL statement Hibernate executes, so a test can assert on query shape. */ public static final class SqlCapture implements org.hibernate.resource.jdbc.spi.StatementInspector { diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index 9263e130..ea9c75b9 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -134,25 +134,7 @@ private static void assertConditionThrows(Operand condition, String... messageFr * Exercises the full path so any IllegalArgumentException during predicate building surfaces. */ private static int runCount(Operand condition) { - PlanResourcesResponse resp = - buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, condition); - Result result = - SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER); - assertInstanceOf(Result.Conditional.class, result); - Specification spec = ((Result.Conditional) result).specification(); - - EntityManager em = emf.createEntityManager(); - try { - CriteriaBuilder cb = em.getCriteriaBuilder(); - CriteriaQuery cq = cb.createQuery(Long.class); - Root root = cq.from(ResourceEntity.class); - cq.select(cb.count(root)); - Predicate p = spec.toPredicate(root, cq, cb); - if (p != null) cq.where(p); - return em.createQuery(cq).getSingleResult().intValue(); - } finally { - em.close(); - } + return runCount(condition, Map.of()); } /** {@link #runCount(Operand)} with per-operator overrides. */ @@ -1126,7 +1108,7 @@ void structValueWithNullEntryDoesNotThrow() { .putFields("a", Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) .putFields("b", Value.newBuilder().setStringValue("x").build()) .build(); - Object converted = SpringDataQueryPlanAdapter.protoValueToJava( + Object converted = PlanValues.protoValueToJava( Value.newBuilder().setStructValue(struct).build()); assertInstanceOf(Map.class, converted); Map map = (Map) converted; @@ -1138,28 +1120,10 @@ void structValueWithNullEntryDoesNotThrow() { @Test void operatorOverrideIsUsed() { Operand cond = exprOp("eq", var("request.resource.attr.aString"), sval("foo")); - PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, cond); - - // Override eq to always produce IS NULL — so result count is 0 (no nulls in empty table either, still 0). + // Override eq to always produce IS NULL — result count stays 0 and the override path + // is exercised end-to-end (runCount asserts the Conditional kind internally). Map overrides = Map.of( "eq", (cb, field, value) -> cb.isNull(field)); - - Result result = - SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER, overrides); - assertInstanceOf(Result.Conditional.class, result); - - Specification spec = ((Result.Conditional) result).specification(); - EntityManager em = emf.createEntityManager(); - try { - CriteriaBuilder cb = em.getCriteriaBuilder(); - CriteriaQuery cq = cb.createQuery(Long.class); - Root root = cq.from(ResourceEntity.class); - cq.select(cb.count(root)); - Predicate p = spec.toPredicate(root, cq, cb); - cq.where(p); - assertEquals(0L, em.createQuery(cq).getSingleResult().longValue()); - } finally { - em.close(); - } + assertEquals(0, runCount(cond, overrides)); } } From 69cce6862f5c88120725a929653881dd6ee33816 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Thu, 16 Jul 2026 10:32:32 +0100 Subject: [PATCH 11/20] fix(spring-data): defend against Hibernate 6 negation collapse; compare fractional constants in double space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs found by a new adversarial differential suite that compares adapter-filtered results against a per-row check-API oracle on a real PDP: - Hibernate 6's SQM negation is stateful for comparison predicates: cb.not(cb.not(p)) stays negated, so !(!R.attr.aBool) silently returned the inverted row set. Translator.negate() now wraps predicates in a single-element junction (cb.not(cb.and(p))) so nested negations compose; applied to not, all, except, and the size()==0 path. - R.attr.intAttr >= 1.5 threw CoercionException ("not a whole number"). Fractional constants now compare via path.as(Double.class), which also gives intColumn == 1.5 correct always-false semantics. The harness (AdversarialConformanceTest + adversarial-policy.yaml) seeds hostile rows — empty collections, LIKE metacharacters, unicode, empty strings, negatives, duplicate tag names — and exercises value-first comparisons, in normalization, negation compositions (double/triple, !(size==0)), outer attributes two lambda levels deep, and DB-NULL vs missing-attribute alignment. Oracle expectations come from Cerbos itself; a guard test rejects degenerate (all-allow/all-deny) oracles. Runs in an isolated persistence unit. 228 tests pass in the Docker suite. Signed-off-by: Alex Olivier --- .../SpringDataQueryPlanAdapter.java | 32 +- .../AdversarialConformanceTest.java | 276 ++++++++++++++++++ .../test/resources/META-INF/persistence.xml | 25 ++ .../test/resources/adversarial-policy.yaml | 194 ++++++++++++ 4 files changed, 519 insertions(+), 8 deletions(-) create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java create mode 100644 spring-data/src/test/resources/adversarial-policy.yaml diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index e4405aea..93ce07bd 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -101,6 +101,17 @@ private Predicate handleBareVariable(String variable, Scope scope) { return applyLeaf("eq", path, true); } + /** + * Logical negation with a junction barrier. Hibernate 6's SQM negation is stateful for + * comparison predicates: {@code cb.not(cb.not(p))} stays negated instead of toggling + * back (verified against Hibernate 6.6.18 — a double-negated {@code eq} still renders + * a single {@code NOT}). Wrapping in a single-element conjunction gives each {@code not} + * a fresh node to negate, so nested negations compose correctly. + */ + private Predicate negate(Predicate p) { + return cb.not(cb.and(p)); + } + private Predicate traverseExpression(PlanResourcesFilter.Expression expression, Scope scope) { String op = expression.getOperator(); List operands = expression.getOperandsList(); @@ -114,7 +125,7 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, if (operands.size() != 1) { throw new IllegalArgumentException("not requires exactly 1 operand"); } - yield cb.not(traverse(operands.get(0), scope)); + yield negate(traverse(operands.get(0), scope)); } case "exists", "exists_one", "all", "except", "filter" -> handleCollectionOperator(op, operands, scope); @@ -277,10 +288,15 @@ private Predicate applyLeaf(String op, Path path, Object value) { @SuppressWarnings({"rawtypes", "unchecked"}) private Predicate defaultLeaf(String op, Path path, Object value) { - Path raw = path; + // Fractional constants compare in double space: protoValueToJava yields Double only + // for non-whole numbers, and Hibernate refuses to coerce e.g. 1.5 into an + // Integer-typed path ("not a whole number") — but `intColumn >= 1.5` is legal CEL + // that the planner emits verbatim. + jakarta.persistence.criteria.Expression raw = + (value instanceof Double) ? path.as(Double.class) : path; return switch (op) { - case "eq" -> cb.equal(path, value); - case "ne" -> cb.notEqual(path, value); + case "eq" -> cb.equal(raw, value); + case "ne" -> cb.notEqual(raw, value); case "lt" -> cb.lessThan(raw, (Comparable) value); case "gt" -> cb.greaterThan(raw, (Comparable) value); case "le" -> cb.lessThanOrEqualTo(raw, (Comparable) value); @@ -589,7 +605,7 @@ private Predicate trySizeComparison(String op, List operands, Scope sco return existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.conjunction()); } if (empty) { - return cb.not(existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.conjunction())); + return negate(existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.conjunction())); } throw new IllegalArgumentException( "Unsupported size comparison: size(" + var + ") " + op + " " + numValue @@ -636,10 +652,10 @@ private Predicate handleCollectionOperator(String op, List operands, Sc (sub, joinFrom, correlated) -> traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub)))); case "except" -> existsSubquery(scope, rel, - (sub, joinFrom, correlated) -> cb.not(traverse(body, + (sub, joinFrom, correlated) -> negate(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub))))); - case "all" -> cb.not(existsSubquery(scope, rel, - (sub, joinFrom, correlated) -> cb.not(traverse(body, + case "all" -> negate(existsSubquery(scope, rel, + (sub, joinFrom, correlated) -> negate(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub)))))); case "exists_one" -> { Subquery sub = scope.parentQuery().subquery(Long.class); diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java new file mode 100644 index 00000000..39ae9c7d --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java @@ -0,0 +1,276 @@ +package dev.cerbos.queryplan.springdata; + +import dev.cerbos.queryplan.springdata.testmodel.CategoryEntity; +import dev.cerbos.queryplan.springdata.testmodel.ResourceEntity; +import dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity; +import dev.cerbos.sdk.CerbosBlockingClient; +import dev.cerbos.sdk.CerbosClientBuilder; +import dev.cerbos.sdk.PlanResourcesResult; +import dev.cerbos.sdk.builders.AttributeValue; +import dev.cerbos.sdk.builders.Principal; +import dev.cerbos.sdk.builders.Resource; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import org.springframework.data.jpa.domain.Specification; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Adversarial differential suite: every action in {@code adversarial-policy.yaml} is planned + * against a REAL Cerbos PDP, translated by the adapter, and executed against seeded rows — then + * the filtered id set is compared against an oracle computed by calling the PDP's + * check API for each row with attributes mirroring that row exactly. + * + *

No hand-computed expectations: if the adapter's SQL semantics diverge from Cerbos's own + * evaluation for any row, the mismatch surfaces mechanically. Seed rows deliberately hold + * hostile data — empty collections, LIKE metacharacters ({@code % _ \}), unicode, empty strings, + * negative numbers — and the policies use planner shapes the conformance policies don't + * (value-first comparisons, empty {@code in} lists, fractional thresholds against integer + * columns, outer attribute references two lambda levels deep). + */ +class AdversarialConformanceTest { + + private static final Map MAPPING = Map.ofEntries( + Map.entry("request.resource.attr.aBool", AttributeMapping.field("aBool")), + Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), + Map.entry("request.resource.attr.aNumber", AttributeMapping.field("aNumber")), + Map.entry("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")), + Map.entry("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "id", AttributeMapping.field("id"), + "name", AttributeMapping.field("name") + ))), + Map.entry("request.resource.attr.categories", AttributeMapping.relation("categories", Map.of( + "name", AttributeMapping.field("name"), + "subCategories", AttributeMapping.relation("subCategories", Map.of( + "name", AttributeMapping.field("name") + )) + ))) + ); + + private record Tag(String id, String name) {} + + /** One seeded row; the single source of truth for BOTH the DB entity and the oracle attributes. */ + private record Seed(String id, boolean aBool, String aString, int aNumber, + String aOptionalString, List tags, List subCategoryNames) {} + + private static final List SEEDS = List.of( + new Seed("a1", true, "one", 5, "set", + List.of(new Tag("t1a", "public")), List.of("finance")), + new Seed("a2", false, "100%_done", -2, null, + List.of(), List.of()), + new Seed("a3", true, "100xdone", 2, "x", + List.of(new Tag("t3a", "public"), new Tag("t3b", "public")), List.of()), + new Seed("a4", true, "xa_by", 1, null, + List.of(new Tag("t4a", "private")), List.of()), + new Seed("a5", false, "xaXby", -5, "y", + List.of(new Tag("t5a", "public")), List.of()), + new Seed("a6", true, "héllo🚀", 3, "", + List.of(new Tag("t6a", "public"), new Tag("t6b", "private")), List.of("finance")), + new Seed("a7", true, "tail\\", 0, "z", + List.of(new Tag("t7a", "other")), List.of()), + new Seed("a8", true, "", 2, null, + List.of(new Tag("t8a", "public")), List.of("tech")) + ); + + private static GenericContainer cerbos; + private static CerbosBlockingClient client; + private static EntityManagerFactory emf; + + @BeforeAll + static void setUp() throws Exception { + cerbos = new GenericContainer<>("ghcr.io/cerbos/cerbos:latest") + .withExposedPorts(3593) + .withCommand("server", "--set=storage.disk.directory=/policies") + .withEnv("CERBOS_NO_TELEMETRY", "1") + .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("cerbos-adversarial-pdp"))) + .waitingFor(Wait.forLogMessage(".*Starting gRPC server.*", 1)); + try (InputStream policy = AdversarialConformanceTest.class + .getResourceAsStream("/adversarial-policy.yaml")) { + cerbos.withCopyToContainer( + Transferable.of(policy.readAllBytes()), "/policies/adversarial.yaml"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + cerbos.start(); + client = new CerbosClientBuilder(cerbos.getHost() + ":" + cerbos.getMappedPort(3593)) + .withPlaintext().buildBlockingClient(); + + emf = Persistence.createEntityManagerFactory("adversarial-pu"); + seed(); + } + + @AfterAll + static void tearDown() { + if (emf != null) emf.close(); + if (cerbos != null) cerbos.stop(); + } + + private static void seed() { + EntityManager em = emf.createEntityManager(); + EntityTransaction tx = em.getTransaction(); + tx.begin(); + + // Distinct sub-category/category graphs per seed so no rows share relations by accident. + int catSeq = 0; + for (Seed s : SEEDS) { + ResourceEntity r = new ResourceEntity(s.id()); + r.setaBool(s.aBool()); + r.setaString(s.aString()); + r.setaNumber(s.aNumber()); + r.setaOptionalString(s.aOptionalString()); + for (Tag tag : s.tags()) { + r.addTag(tag.id(), tag.name()); + } + List cats = new ArrayList<>(); + for (String subName : s.subCategoryNames()) { + catSeq++; + SubCategoryEntity sub = new SubCategoryEntity("adv-sub-" + catSeq, subName); + em.persist(sub); + CategoryEntity cat = new CategoryEntity("adv-cat-" + catSeq, "business"); + cat.setSubCategories(new ArrayList<>(List.of(sub))); + em.persist(cat); + cats.add(cat); + } + r.setCategories(cats); + em.persist(r); + } + tx.commit(); + em.close(); + } + + // -- oracle: ask the PDP itself, row by row -- + + private static Principal principal() { + return Principal.newInstance("u1", "USER") + .withAttribute("allowedTags", AttributeValue.listValue( + AttributeValue.stringValue("public"), + AttributeValue.stringValue("special"))); + } + + /** Cerbos attributes mirroring exactly what the seeded DB row holds. */ + private static Resource asCheckResource(Seed s) { + Resource r = Resource.newInstance("adversarial", s.id()) + .withAttribute("aBool", AttributeValue.boolValue(s.aBool())) + .withAttribute("aString", AttributeValue.stringValue(s.aString())) + .withAttribute("aNumber", AttributeValue.doubleValue(s.aNumber())) + .withAttribute("tags", AttributeValue.listValue(s.tags().stream() + .map(t -> AttributeValue.mapValue(Map.of( + "id", AttributeValue.stringValue(t.id()), + "name", AttributeValue.stringValue(t.name())))) + .toList())) + .withAttribute("categories", AttributeValue.listValue(s.subCategoryNames().stream() + .map(subName -> AttributeValue.mapValue(Map.of( + "name", AttributeValue.stringValue("business"), + "subCategories", AttributeValue.listValue( + AttributeValue.mapValue(Map.of( + "name", AttributeValue.stringValue(subName))))))) + .toList())); + // A DB NULL is a missing attribute on the check side — conditions touching it must + // deny (CEL error), matching SQL three-valued logic excluding the row. + if (s.aOptionalString() != null) { + r = r.withAttribute("aOptionalString", AttributeValue.stringValue(s.aOptionalString())); + } + return r; + } + + private static List oracleAllowedIds(String action) { + return SEEDS.stream() + .filter(s -> client.check(principal(), asCheckResource(s), action).isAllowed(action)) + .map(Seed::id) + .sorted() + .toList(); + } + + // -- adapter execution through the public Specification path -- + + private static List adapterFilteredIds(String action) { + PlanResourcesResult plan = client.plan(principal(), Resource.newInstance("adversarial"), action); + Specification spec = + SpringDataQueryPlanAdapter.toSpecification(plan, MAPPING).toSpecification(); + + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root root = cq.from(ResourceEntity.class); + cq.select(root.get("id")).distinct(true); + Predicate p = spec.toPredicate(root, cq, cb); + if (p != null) { + cq.where(p); + } + cq.orderBy(cb.asc(root.get("id"))); + return em.createQuery(cq).getResultList(); + } finally { + em.close(); + } + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "vf-le", "vf-ge", "vf-ne", + "in-single", "in-empty", + "like-percent", "like-underscore", "like-backslash", + "unicode-eq", "empty-string-eq", + "neg-number", "double-threshold", + "all-on-empty", "exists-on-empty", "exists-one-multi", "not-exists", + "outer-attr-depth2", "lambda-in-principal", + "nary-and", "double-negation", "triple-negation", "not-empty", + "optional-ne", + }) + void adapterMatchesCheckOracle(String action) { + List oracle = oracleAllowedIds(action); + List filtered = adapterFilteredIds(action); + assertEquals(oracle, filtered, + "adapter result diverges from check-API oracle for action '" + action + "'"); + } + + @Test + void fieldToFieldInsideLambdaFailsLoudly() { + // Documented unsupported shape — must be a clear error, never a silently wrong result. + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> adapterFilteredIds("lambda-field-to-field")); + assertTrue(ex.getMessage().contains("Field-to-field"), + "expected the field-to-field guard, got: " + ex.getMessage()); + } + + @Test + void oracleIsNotDegenerate() { + // Guard the guard: at least one action must produce a non-empty, non-total oracle set, + // otherwise the differential comparison could pass vacuously (e.g. PDP denying all). + Map> samples = new LinkedHashMap<>(); + samples.put("vf-le", oracleAllowedIds("vf-le")); + samples.put("like-percent", oracleAllowedIds("like-percent")); + samples.put("all-on-empty", oracleAllowedIds("all-on-empty")); + samples.forEach((action, ids) -> assertTrue( + !ids.isEmpty() && ids.size() < SEEDS.size(), + "oracle for '" + action + "' is degenerate: " + ids)); + } +} diff --git a/spring-data/src/test/resources/META-INF/persistence.xml b/spring-data/src/test/resources/META-INF/persistence.xml index 52f10ca1..68407233 100644 --- a/spring-data/src/test/resources/META-INF/persistence.xml +++ b/spring-data/src/test/resources/META-INF/persistence.xml @@ -26,4 +26,29 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvider + dev.cerbos.queryplan.springdata.testmodel.ResourceEntity + dev.cerbos.queryplan.springdata.testmodel.TagEntity + dev.cerbos.queryplan.springdata.testmodel.CategoryEntity + dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity + dev.cerbos.queryplan.springdata.testmodel.LabelEntity + dev.cerbos.queryplan.springdata.testmodel.OwnerEntity + dev.cerbos.queryplan.springdata.testmodel.NestedEmbeddable + dev.cerbos.queryplan.springdata.testmodel.NextLevelEmbeddable + true + + + + + + + + + + + diff --git a/spring-data/src/test/resources/adversarial-policy.yaml b/spring-data/src/test/resources/adversarial-policy.yaml new file mode 100644 index 00000000..078245cf --- /dev/null +++ b/spring-data/src/test/resources/adversarial-policy.yaml @@ -0,0 +1,194 @@ +# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json +# +# Hostile policy shapes for the adversarial differential test. Each action is exercised through +# a REAL PDP and the adapter's filtered result set is compared against an oracle computed by +# calling the check API per seeded row — no hand-computed expectations. + +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: adversarial + rules: + # -- value-first comparisons (planner preserves source order) -- + - actions: ["vf-le"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 3 <= R.attr.aNumber + + - actions: ["vf-ge"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 2 >= R.attr.aNumber + + - actions: ["vf-ne"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '"one" != R.attr.aString' + + # -- in normalization (single element -> eq; empty -> always-denied) -- + - actions: ["in-single"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString in ["one"] + + - actions: ["in-empty"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString in [] + + # -- LIKE metacharacters must be escaped, not interpreted -- + - actions: ["like-percent"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString.startsWith("100%") + + - actions: ["like-underscore"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString.contains("a_b") + + - actions: ["like-backslash"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString.endsWith("\\") + + # -- string edge values -- + - actions: ["unicode-eq"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString == "héllo🚀" + + - actions: ["empty-string-eq"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString == "" + + # -- numeric edges -- + - actions: ["neg-number"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aNumber < -1 + + - actions: ["double-threshold"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aNumber >= 1.5 + + # -- collection macro semantics on empty/multi collections -- + - actions: ["all-on-empty"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.tags.all(t, t.name == "public") + + - actions: ["exists-on-empty"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.tags.exists(t, t.name == "public") + + - actions: ["exists-one-multi"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.tags.exists_one(t, t.name == "public") + + - actions: ["not-exists"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!R.attr.tags.exists(t, t.name == "private")' + + # -- outer resource attribute at TWO lambda depths (correlation-rebase probe) -- + - actions: ["outer-attr-depth2"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.categories.exists(c, c.subCategories.exists(s, s.name == "finance" && R.attr.aBool)) + + # -- principal list folded into a lambda-scoped in -- + - actions: ["lambda-in-principal"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.tags.exists(t, t.name in P.attr.allowedTags) + + # -- logical shapes -- + - actions: ["nary-and"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + all: + of: + - expr: R.attr.aBool + - expr: R.attr.aNumber >= 0 + - expr: R.attr.aString != "one" + + - actions: ["double-negation"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!(!R.attr.aBool)' + + - actions: ["triple-negation"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!(!(!R.attr.aBool))' + + # not over NOT EXISTS (size==0) — negation composing over a negated subquery predicate + - actions: ["not-empty"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!(size(R.attr.tags) == 0)' + + # -- null-attribute alignment: CEL missing-attr error (deny) vs SQL NULL (excluded) -- + - actions: ["optional-ne"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aOptionalString != "x" + + # -- documented unsupported shape: field-to-field inside a lambda must throw cleanly -- + - actions: ["lambda-field-to-field"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.tags.exists(t, t.name == R.attr.aString) From d7dedc38cb37d71286e9dd4928ec6586e8481eac Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Thu, 16 Jul 2026 12:38:27 +0100 Subject: [PATCH 12/20] docs(spring-data): fix Page return type in README; document test suites; mount docker socket in e2e fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the PR test plan end to end: - scripts/run-e2e.sh (external-PDP mode): 206 PlanResources calls served by the compose-managed PDP, all tests green. The gradle-in-Docker fallback now mounts the docker socket, required since AdversarialConformanceTest spawns its own PDP via Testcontainers even in external-PDP mode. - README quick-start compiled against the built jar: the composition snippet assigned findAll(spec, pageable) to List, but that overload returns Page — fixed. - Added a suite-roles table to the testing section (unit / integration / adversarial-differential) so the purpose of each test class is discoverable without reading the sources. Signed-off-by: Alex Olivier --- spring-data/README.md | 12 ++++++++++-- spring-data/scripts/run-e2e.sh | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/spring-data/README.md b/spring-data/README.md index 8a00e287..28960d02 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -83,7 +83,7 @@ Compose it with your own filters: Specification own = (root, query, cb) -> cb.like(root.get("name"), "Smith%"); -List results = contactRepository.findAll( +Page results = contactRepository.findAll( own.and(result.toSpecification()), pageable); ``` @@ -253,7 +253,15 @@ gradle build --no-daemon ## End-to-end testing Every test runs against a **real Cerbos PDP container** — there is no stubbing of policy -evaluation. Two run modes are supported: +evaluation. Three suites with distinct roles: + +| Suite | Role | +|---|---| +| `SpringDataQueryPlanAdapterTest` | Unit: protobuf operands built by hand, executed against H2 to catch translation/mapping errors and pin error messages | +| `SpringDataIntegrationTest` | Integration: the shared `/policies/resource.yaml` conformance actions planned by a live PDP, results asserted against seeded rows | +| `AdversarialConformanceTest` | Differential: hostile policy shapes + hostile seed data (LIKE metacharacters, unicode, empty collections, value-first operand order); the adapter's filtered rows are compared per action against an oracle computed from the PDP's own `check` API — no hand-computed expectations, so any semantic divergence between the generated SQL and Cerbos's evaluation fails mechanically | + +Two run modes are supported: ### 1. Self-managed (default) diff --git a/spring-data/scripts/run-e2e.sh b/spring-data/scripts/run-e2e.sh index f7b6b9d3..3643d4f4 100755 --- a/spring-data/scripts/run-e2e.sh +++ b/spring-data/scripts/run-e2e.sh @@ -43,8 +43,12 @@ if command -v gradle >/dev/null 2>&1; then TEST_EXIT=$? else echo "==> No local gradle found; falling back to gradle:8.12-jdk17 Docker image" + # The docker socket mount is required by AdversarialConformanceTest, which always spawns its + # own PDP (with its own hostile policy set) via Testcontainers even in external-PDP mode. docker run --rm \ -v "$(pwd)/..":/app \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e TESTCONTAINERS_RYUK_DISABLED=true \ --network host \ -e CERBOS_HOST="${CERBOS_HOST}" \ -e CERBOS_PORT="${CERBOS_PORT}" \ From 636e0816663aa1824c0ac73b9e00d73ed9e17725 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Thu, 16 Jul 2026 20:42:21 +0100 Subject: [PATCH 13/20] =?UTF-8?q?fix(spring-data):=20address=20review=20co?= =?UTF-8?q?mments=20=E2=80=94=20protobuf=20version,=20Dockerfile=20tests,?= =?UTF-8?q?=20stale=20README=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Align protobuf-java with the SDK gencode version documented in the README and used by the example (4.31.1 -> 4.33.5); tests previously only passed because Gradle conflict resolution picked the SDK's higher transitive. - Dockerfile: build with -x test — the suite needs a Docker daemon (Testcontainers PDP) and the shared /policies dir, neither available inside docker build. Verified docker build now succeeds. - README: hierarchy operators moved out of "Not yet supported" (shipped in #249) into the supported table, alongside the value-first normalization row. 228/228 tests pass with the bumped protobuf. Signed-off-by: Alex Olivier --- spring-data/Dockerfile | 5 ++++- spring-data/README.md | 3 ++- spring-data/build.gradle.kts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/spring-data/Dockerfile b/spring-data/Dockerfile index 0bce1cdf..933fb0d0 100644 --- a/spring-data/Dockerfile +++ b/spring-data/Dockerfile @@ -2,4 +2,7 @@ FROM gradle:8.12-jdk17 AS build WORKDIR /app COPY build.gradle.kts settings.gradle.kts ./ COPY src ./src -RUN gradle build --no-daemon +# Tests need a Docker daemon (Testcontainers spawns a Cerbos PDP) and the shared /policies +# directory, neither of which exists inside `docker build`. Build artifacts only; run the +# test suite via CI or `docker run` with the docker socket mounted (see CLAUDE.md). +RUN gradle build -x test --no-daemon diff --git a/spring-data/README.md b/spring-data/README.md index 28960d02..eec40b07 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -122,6 +122,8 @@ Map each `request.resource.attr.` to a JPA path or a relation: | Bare boolean variable | `cb.equal(path, true)` | | `eq(field, add(const1, const2))` | Constant fold then compare: `cb.equal(field, const1 ⊕ const2)` | | `eq(value, add(const, field))` | Solve for `field` (string prefix/suffix strip; numeric subtract); unsolvable cases become `1=0` / `1=1` | +| `hierarchy(...).overlaps / ancestorOf / descendentOf` | Segment/prefix predicates (`IN` over ancestor prefixes, `LIKE 'a:b:%'` for descendants), mirroring the Prisma adapter | +| Value-first comparisons (`5 < R.attr.x`) | Normalized field-first with the operator mirrored (`x > 5`) | Unsupported operators raise `IllegalArgumentException` — override them with `OperatorFunction`: @@ -154,7 +156,6 @@ SQL fragments), or wait for adapter support. | `eq(map(...), [...])` | `R.attr.tags.map(t, t.id) == ["tag1", "tag2"]` | Use `hasIntersection(map(...), [...])` instead. | | `size(filter(...)) N` | `size(R.attr.tags.filter(t, t.name == "x")) > 0` | Use `exists(coll, lambda)` for emptiness; `size()` only accepts a Variable operand. | | `size(coll) N` for `N > 0` | `size(R.attr.tags) > 5` | Only emptiness checks are supported. | -| Hierarchy operators (`hierarchy-*`) | `hierarchy.overlaps(...)` | Not yet ported from the Prisma adapter; ~250 LoC follow-up. | ## Gotchas diff --git a/spring-data/build.gradle.kts b/spring-data/build.gradle.kts index 8d19f56d..adfd5c8c 100644 --- a/spring-data/build.gradle.kts +++ b/spring-data/build.gradle.kts @@ -16,7 +16,9 @@ repositories { dependencies { implementation("dev.cerbos:cerbos-sdk-java:0.18.0") - implementation("com.google.protobuf:protobuf-java:4.31.1") + // Must match the gencode version cerbos-sdk-java was generated against (see the README + // "Pin protobuf-java" gotcha) — older runtimes throw ProtobufRuntimeVersionException. + implementation("com.google.protobuf:protobuf-java:4.33.5") // Spring Data JPA + Jakarta Persistence are provided by the consuming application's // Spring Boot BOM (or equivalent). Declaring them as `compileOnly` keeps them out of // the published POM as transitive dependencies so they don't pin a specific version on From 1636b65bd823afd803cf15fab7f95746cfd82558 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 20 Jul 2026 16:44:37 +0100 Subject: [PATCH 14/20] feat(spring-data): support size(string), size(coll) N, field-to-field, and CEL ternary - size(string) -> LENGTH(column) N for Field-mapped attributes - size(coll) N beyond emptiness -> correlated (SELECT COUNT ...) N; size(coll.filter(x, pred)) N counts only lambda-matching elements - field-to-field eq/ne/lt/gt/le/ge -> column-to-column predicates, incl. outer-attribute references inside lambdas - ternary if(cond, then, else) -> predicate rewrite (cond AND cmp(then, v)) OR (NOT cond AND cmp(else, v)); nested, bare-boolean, and value-first forms compose; constant residues fold statically; NULL condition rows are excluded from both arms, matching CEL error->deny - 7 new adversarial differential actions pin the ternary against the check-API oracle (incl. a NULL-condition shape); 253 tests green across the three suites Signed-off-by: Alex Olivier --- policies/resource.yaml | 18 + spring-data/README.md | 32 +- .../springdata/OperatorFunction.java | 4 +- .../SpringDataQueryPlanAdapter.java | 320 ++++++++++- .../AdversarialConformanceTest.java | 20 +- .../springdata/SpringDataIntegrationTest.java | 47 +- .../SpringDataQueryPlanAdapterTest.java | 506 ++++++++++++++++-- .../test/resources/adversarial-policy.yaml | 93 +++- 8 files changed, 957 insertions(+), 83 deletions(-) diff --git a/policies/resource.yaml b/policies/resource.yaml index 10db9857..00edff07 100644 --- a/policies/resource.yaml +++ b/policies/resource.yaml @@ -855,6 +855,24 @@ resourcePolicy: match: expr: request.resource.attr.aString == request.resource.attr.id + - actions: + - "not-equal-field-to-field" + effect: EFFECT_ALLOW + roles: + - USER + condition: + match: + expr: request.resource.attr.aString != request.resource.attr.id + + - actions: + - "size-count-threshold" + effect: EFFECT_ALLOW + roles: + - USER + condition: + match: + expr: size(request.resource.attr.ownedBy) >= 2 + - actions: - "equal-bool-false" effect: EFFECT_ALLOW diff --git a/spring-data/README.md b/spring-data/README.md index eec40b07..19576fee 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -114,6 +114,11 @@ Map each `request.resource.attr.` to a JPA path or a relation: | `hasIntersection(coll.map(x, x.f), [values])` | Correlated `EXISTS` with projected `IN` | | `size(coll) > 0` / `>= 1` | Correlated `EXISTS` | | `size(coll) == 0` / `<= 0` / `< 1`| `NOT EXISTS` | +| `size(coll) N` | Correlated `(SELECT COUNT...) N` | +| `size(coll.filter(x, pred)) N` | Correlated `(SELECT COUNT... WHERE pred) N` | +| `size(string)` | `cb.length(column)` (see Gotchas for astral-character caveat) | +| Field-to-field (`R.attr.a == R.attr.b`) | `cb.equal(pathA, pathB)` and friends for `eq`/`ne`/`lt`/`gt`/`le`/`ge`, incl. inside lambdas | +| Ternary (`cond ? a : b`) | Predicate rewrite: `(cond AND cmp(a, v)) OR (NOT cond AND cmp(b, v))` — nested, boolean-position, and value-first forms compose; constant residues fold (see Gotchas for `NULL` semantics) | | `exists(coll, lambda)` | Correlated `EXISTS` with lambda body | | `exists_one(coll, lambda)` | Correlated `(SELECT COUNT...) = 1` | | `all(coll, lambda)` | `NOT EXISTS (... AND NOT(body))` | @@ -150,18 +155,35 @@ SQL fragments), or wait for adapter support. | Regex match | `R.attr.aString.matches("^foo.*")` | JPA has no portable regex predicate; override per-dialect (`regexp_like`, `~`, `REGEXP`). | | List indexing | `R.attr.tags[0] == "x"` | JPA collections are unordered sets — no positional access. | | Type casts (`int(...)` / `double(...)` / `string(...)`) | `int(R.attr.aString) > 0` | No portable `CAST` in Criteria; override per-dialect. | -| Ternary (`cond ? a : b`) | `(R.attr.aBool ? R.attr.aNumber : 0) > 0` | The CEL planner emits this as `if(cond, then, else)`; JPA Criteria has no `CASE WHEN` value-expression builder. | -| `size(string)` | `size(R.attr.aString) > 0` | Only `size(collection)` (`Relation` mapping) is supported; for strings use `cb.length` via an override. | -| Field-to-field comparison | `R.attr.aString == R.attr.id` | The leaf operator handler requires one variable + one value operand; throws explicitly. | +| Field-to-field `contains`/`startsWith`/`endsWith` | `R.attr.aString.contains(R.attr.createdBy)` | Comparison operators support field-to-field; LIKE against a column-derived pattern has no portable escaping. | | `eq(map(...), [...])` | `R.attr.tags.map(t, t.id) == ["tag1", "tag2"]` | Use `hasIntersection(map(...), [...])` instead. | -| `size(filter(...)) N` | `size(R.attr.tags.filter(t, t.name == "x")) > 0` | Use `exists(coll, lambda)` for emptiness; `size()` only accepts a Variable operand. | -| `size(coll) N` for `N > 0` | `size(R.attr.tags) > 5` | Only emptiness checks are supported. | ## Gotchas Things you're likely to hit when integrating the adapter into a Spring Boot app — see the [`example/`](example) photo-sharing application for a runnable end-to-end reference. +### `size(string)` counts differently for astral characters + +CEL's `size(string)` counts Unicode code points; the adapter translates it to SQL +`LENGTH()`, whose unit varies by database (UTF-16 units on H2, characters on PostgreSQL, +bytes on some MySQL collations). The two only diverge for characters outside the Basic +Multilingual Plane (emoji, some CJK extensions): `size("héllo🚀")` is 6 in CEL but +`LENGTH` may report 7. If your data contains astral characters and a policy compares +lengths near those values, rows can be filtered differently than a `check` call would +decide. Keep length thresholds away from values that straddle the difference, or avoid +`size(string)` in policies over data that contains astral characters. + +### Ternary with a `NULL` condition column excludes the row + +A comparison wrapping a ternary is rewritten as +`(cond AND cmp(then, v)) OR (NOT cond AND cmp(else, v))`. Under SQL three-valued +logic a `NULL` condition column makes both arms unknown, so the row matches +neither branch. This is deliberate: in CEL a null/missing ternary condition is +an evaluation error, and Cerbos denies the check — the SQL filter and a +per-resource `check` call agree. It differs from what a SQL `CASE WHEN` would +do (fall through to the `ELSE` branch). + ### Pin `protobuf-java` to the cerbos-sdk-java's gencode version `cerbos-sdk-java` 0.18.0 ships protobuf message classes generated against diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java index 26c13e09..94491eb7 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java @@ -23,7 +23,9 @@ *

Overrides are not consulted for operators that translate to correlated {@code EXISTS} * subqueries against a {@code Relation} mapping — {@code exists}/{@code exists_one}/{@code all}/ * {@code except}/{@code filter}, {@code hasIntersection} over a relation, {@code size(...)}, and the - * relation form of {@code in} — because those have no single resolved (field, value) pair. + * relation form of {@code in} — because those have no single resolved (field, value) pair. The same + * applies to {@code size(string)} length comparisons and field-to-field comparisons + * ({@code R.attr.a == R.attr.b}), where the right-hand side is a column, not a value. */ @FunctionalInterface public interface OperatorFunction { diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 93ce07bd..c1b304e5 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -15,6 +15,8 @@ import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; /** * Translates a Cerbos {@code PlanResources} response into a Spring Data JPA @@ -133,10 +135,15 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, case "hasIntersection", "has_intersection" -> handleHasIntersection(operands, scope); case "isSet" -> handleIsSet(operands, scope); case "in" -> handleIn(operands, scope); + case "if" -> handleBareTernary(operands, scope); case "overlaps" -> hierarchy.handleOverlaps(operands, scope); case "ancestorOf" -> hierarchy.handleAncestorDescendant(operands, scope, true); case "descendentOf" -> hierarchy.handleAncestorDescendant(operands, scope, false); default -> { + Predicate ternaryPred = tryTernaryComparison(op, operands, scope); + if (ternaryPred != null) { + yield ternaryPred; + } NormalizedBinary nb = NormalizedBinary.of(op, operands); Predicate sizePred = trySizeComparison(nb.op(), nb.operands(), scope); if (sizePred != null) { @@ -185,10 +192,166 @@ private static String mirror(String op) { } } + // -- if (CEL ternary) -- + + /** Binary comparison operators that accept a ternary operand (see {@link #tryTernaryComparison}). */ + private static final Set TERNARY_COMPARISONS = + Set.of("eq", "ne", "lt", "gt", "le", "ge"); + + /** + * Rewrite a comparison wrapping a CEL ternary — {@code cmp(if(c, a, b), other)} — into a + * pure predicate: + * + *

{@code (pred(c) AND cmp(a, other)) OR (NOT pred(c) AND cmp(b, other))}
+ * + * We rewrite instead of emitting {@code CASE WHEN} ({@code cb.selectCase}) because this + * translator is predicate-only: every existing typed leaf path — field-first + * normalization, size() handling, add-fold, fractional double-space comparison — operates + * on comparison predicates. Substituting each branch back into the comparison and + * recursing through {@link #traverseExpression} routes the branches through those exact + * paths, so a ternary branch behaves identically to the same comparison written directly. + * Recursion also handles nested ternaries and a ternary on the other side for free. + * + *

Null semantics: under SQL three-valued logic a NULL condition column makes both + * {@code pred(c)} and {@code NOT pred(c)} unknown, so the row is excluded from both + * branches. This matches Cerbos: a null/missing condition in a CEL ternary is an + * evaluation error and the check denies. + * + * @return the rewritten predicate, or {@code null} if this comparison involves no ternary + */ + private Predicate tryTernaryComparison(String op, List operands, Scope scope) { + if (!TERNARY_COMPARISONS.contains(op) || operands.size() != 2) { + return null; + } + int idx = -1; + for (int i = 0; i < operands.size(); i++) { + Operand o = operands.get(i); + if (o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "if".equals(o.getExpression().getOperator())) { + idx = i; + break; + } + } + if (idx < 0) { + return null; + } + List ifOps = operands.get(idx).getExpression().getOperandsList(); + if (ifOps.size() != 3) { + throw new IllegalArgumentException( + "if (ternary) requires exactly 3 operands (condition, then, else), got " + + ifOps.size()); + } + Operand condition = ifOps.get(0); + Operand thenBranch = ifOps.get(1); + Operand elseBranch = ifOps.get(2); + + // A constant boolean condition folds to a single branch — translate only that branch + // so an untranslatable dead branch cannot fail the whole plan. + if (condition.getNodeCase() == Operand.NodeCase.VALUE) { + Boolean known = constantBooleanOrNull(condition); + if (known == null) { + throw new IllegalArgumentException( + "if (ternary) condition must be a boolean expression"); + } + return traverseExpression( + substituteOperand(op, operands, idx, known ? thenBranch : elseBranch), scope); + } + + Predicate thenCmp = traverseExpression( + substituteOperand(op, operands, idx, thenBranch), scope); + Predicate elseCmp = traverseExpression( + substituteOperand(op, operands, idx, elseBranch), scope); + // Translate the condition once per occurrence: Hibernate 6 negation is stateful (see + // negate()), so sharing one Predicate node between the positive and negated arms is + // unsafe. + return cb.or( + cb.and(traverse(condition, scope), thenCmp), + cb.and(negate(traverse(condition, scope)), elseCmp)); + } + + /** + * A CEL ternary in boolean position — {@code if(c, a, b)} used directly as a condition, + * so both branches are themselves boolean. Same predicate rewrite (and same rationale and + * null semantics) as {@link #tryTernaryComparison}: + * + *

{@code (pred(c) AND pred(a)) OR (NOT pred(c) AND pred(b))}
+ */ + private Predicate handleBareTernary(List operands, Scope scope) { + if (operands.size() != 3) { + throw new IllegalArgumentException( + "if (ternary) requires exactly 3 operands (condition, then, else), got " + + operands.size()); + } + Operand condition = operands.get(0); + Operand thenBranch = operands.get(1); + Operand elseBranch = operands.get(2); + + // A constant boolean condition folds to a single branch — translate only that branch + // so an untranslatable dead branch cannot fail the whole plan. + if (condition.getNodeCase() == Operand.NodeCase.VALUE) { + Boolean known = constantBooleanOrNull(condition); + if (known == null) { + throw new IllegalArgumentException( + "if (ternary) condition must be a boolean expression"); + } + return booleanBranchPredicate(known ? thenBranch : elseBranch, scope); + } + + // Translate the condition once per occurrence — see tryTernaryComparison. + return cb.or( + cb.and(traverse(condition, scope), booleanBranchPredicate(thenBranch, scope)), + cb.and(negate(traverse(condition, scope)), booleanBranchPredicate(elseBranch, scope))); + } + + /** + * A ternary branch in boolean position: a boolean VALUE folds to the always-true / + * always-false predicate (the same collapse the unsolvable add-solve cases use); anything + * else translates as a normal boolean operand (bare variables become {@code path = true}). + */ + private Predicate booleanBranchPredicate(Operand branch, Scope scope) { + if (branch.getNodeCase() == Operand.NodeCase.VALUE) { + Boolean constant = constantBooleanOrNull(branch); + if (constant == null) { + throw new IllegalArgumentException( + "if (ternary) branch in boolean position must be a boolean"); + } + return constant ? cb.conjunction() : cb.disjunction(); + } + return traverse(branch, scope); + } + + /** Rebuild {@code op(operands...)} with the operand at {@code idx} replaced. */ + private static PlanResourcesFilter.Expression substituteOperand( + String op, List operands, int idx, Operand replacement) { + PlanResourcesFilter.Expression.Builder b = + PlanResourcesFilter.Expression.newBuilder().setOperator(op); + for (int i = 0; i < operands.size(); i++) { + b.addOperands(i == idx ? replacement : operands.get(i)); + } + return b.build(); + } + + /** The operand's boolean constant, or {@code null} if it is not a boolean VALUE. */ + private static Boolean constantBooleanOrNull(Operand o) { + return PlanValues.protoValueToJava(o.getValue()) instanceof Boolean b ? b : null; + } + // -- Leaf operators (eq/ne/lt/gt/le/ge/contains/startsWith/endsWith) -- /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ private Predicate handleLeafOperator(String op, List operands, Scope scope) { + // Constant-vs-constant comparisons are statically evaluated. The planner never emits + // them directly, but ternary substitution produces them — the else branch of + // `(aBool ? aNumber : 0) > 0` becomes gt(value(0), value(0)). + if (TERNARY_COMPARISONS.contains(op) + && operands.size() == 2 + && operands.get(0).getNodeCase() == Operand.NodeCase.VALUE + && operands.get(1).getNodeCase() == Operand.NodeCase.VALUE) { + return constantComparison(op, + PlanValues.protoValueToJava(operands.get(0).getValue()), + PlanValues.protoValueToJava(operands.get(1).getValue())); + } + // Detect leaf comparisons where one side is an 'add' expression (e.g. string // concatenation: `aString == "prefix:" + R.attr.id`). We fold constants and solve for // the field side when possible — same algorithm as the Prisma adapter. @@ -210,20 +373,17 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc } String variable = null; + String secondVariable = null; Object value = null; boolean valueSeen = false; for (Operand o : operands) { switch (o.getNodeCase()) { case VARIABLE -> { if (variable != null) { - // H1: field-to-field comparison is not expressible in JPA Criteria as a - // value-bound predicate. Surface this explicitly rather than the generic - // "Missing value operand" message the loop would otherwise produce. - throw new IllegalArgumentException( - "Field-to-field comparison is not supported for operator '" - + op + "': " + variable + " vs " + o.getVariable()); + secondVariable = o.getVariable(); + } else { + variable = o.getVariable(); } - variable = o.getVariable(); } case VALUE -> { value = PlanValues.protoValueToJava(o.getValue()); @@ -250,6 +410,9 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc if (variable == null) { throw new IllegalArgumentException("Missing variable operand for " + op); } + if (secondVariable != null) { + return fieldToFieldComparison(op, variable, secondVariable, scope); + } if (!valueSeen) { throw new IllegalArgumentException("Missing value operand for " + op); } @@ -273,6 +436,73 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc return applyLeaf(op, path, value); } + /** + * Statically evaluate a comparison between two plan constants and collapse it to an + * always-true ({@code 1=1}) or always-false ({@code 1=0}) predicate — the same collapse + * the unsolvable {@code add}-solve cases use. Numbers compare in double space: protobuf + * {@code Value.getNumberValue()} is a double, and {@link PlanValues#protoValueToJava} + * only splits Long/Double for whole-number cosmetics, not semantics. Strings compare + * lexicographically; booleans (and mixed incomparable types) support eq/ne only — + * eq → false, ne → true — while ordering them is a planner bug and throws. + */ + private Predicate constantComparison(String op, Object left, Object right) { + boolean result; + if ("eq".equals(op) || "ne".equals(op)) { + boolean equal = (left instanceof Number ln && right instanceof Number rn) + ? ln.doubleValue() == rn.doubleValue() + : Objects.equals(left, right); + result = "eq".equals(op) == equal; + } else { + int cmp; + if (left instanceof Number ln && right instanceof Number rn) { + cmp = Double.compare(ln.doubleValue(), rn.doubleValue()); + } else if (left instanceof String ls && right instanceof String rs) { + cmp = ls.compareTo(rs); + } else { + throw new IllegalArgumentException( + "Cannot order constant operands of " + op + ": " + + typeName(left) + " vs " + typeName(right)); + } + result = switch (op) { + case "lt" -> cmp < 0; + case "gt" -> cmp > 0; + case "le" -> cmp <= 0; + case "ge" -> cmp >= 0; + default -> throw new IllegalArgumentException( + "Unsupported constant comparison operator: " + op); + }; + } + return result ? cb.conjunction() : cb.disjunction(); + } + + private static String typeName(Object o) { + return o == null ? "null" : o.getClass().getSimpleName(); + } + + /** + * Compare two mapped columns directly (eq/ne/lt/gt/le/ge). Operand source order is + * preserved — two variables rank equally, so {@link NormalizedBinary} never swaps them. + * Non-comparison operators (contains/startsWith/endsWith) have no portable JPA + * column-to-column translation and still throw. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Predicate fieldToFieldComparison(String op, String leftVar, String rightVar, + Scope scope) { + jakarta.persistence.criteria.Expression left = scope.resolvePath(leftVar); + jakarta.persistence.criteria.Expression right = scope.resolvePath(rightVar); + return switch (op) { + case "eq" -> cb.equal(left, right); + case "ne" -> cb.notEqual(left, right); + case "lt" -> cb.lessThan(left, right); + case "gt" -> cb.greaterThan(left, right); + case "le" -> cb.lessThanOrEqualTo(left, right); + case "ge" -> cb.greaterThanOrEqualTo(left, right); + default -> throw new IllegalArgumentException( + "Field-to-field comparison is not supported for operator '" + op + "': " + + leftVar + " vs " + rightVar); + }; + } + /** * Apply a scalar leaf operator, consulting the per-operator {@code overrides} hook first so a * registered {@link OperatorFunction} wins on EVERY path that produces this operator — direct @@ -587,14 +817,55 @@ private Predicate trySizeComparison(String op, List operands, Scope sco return null; } List sizeOps = sizeExpr.getOperandsList(); - if (sizeOps.size() != 1 || sizeOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE) { + if (sizeOps.size() != 1) { + throw new IllegalArgumentException("Unsupported size() expression"); + } + Operand sizeArg = sizeOps.get(0); + String var; + Operand lambdaBody = null; + String lambdaVarName = null; + if (sizeArg.getNodeCase() == Operand.NodeCase.VARIABLE) { + var = sizeArg.getVariable(); + } else if (sizeArg.getNodeCase() == Operand.NodeCase.EXPRESSION + && "filter".equals(sizeArg.getExpression().getOperator())) { + // size(coll.filter(x, pred)) — count only the elements matching the lambda. + List filterOps = sizeArg.getExpression().getOperandsList(); + if (filterOps.size() != 2 + || filterOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE + || filterOps.get(1).getNodeCase() != Operand.NodeCase.EXPRESSION + || !"lambda".equals(filterOps.get(1).getExpression().getOperator())) { + throw new IllegalArgumentException("Unsupported size(filter(...)) expression"); + } + var = filterOps.get(0).getVariable(); + List lambdaOps = filterOps.get(1).getExpression().getOperandsList(); + if (lambdaOps.size() != 2 + || lambdaOps.get(1).getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("lambda requires exactly 2 operands"); + } + lambdaBody = lambdaOps.get(0); + lambdaVarName = lambdaOps.get(1).getVariable(); + } else { throw new IllegalArgumentException("Unsupported size() expression"); } - String var = sizeOps.get(0).getVariable(); AttributeMapping mapping = scope.resolveMapping(var); + if (mapping instanceof AttributeMapping.Field) { + // size(string) — CEL string length → LENGTH(column) N. + if (lambdaBody != null) { + throw new IllegalArgumentException( + "size(filter(...)) requires a collection (Relation) mapping for " + var); + } + Path path = scope.resolvePath(var); + return compareCount(cb.length(path.as(String.class)), op, numValue.intValue()); + } if (!(mapping instanceof AttributeMapping.Relation rel)) { throw new IllegalArgumentException("size() requires a collection (Relation) mapping for " + var); } + final Operand fBody = lambdaBody; + final String fVar = lambdaVarName; + SubqueryBodyBuilder bodyBuilder = (sub, joinFrom, correlated) -> + fBody == null ? cb.conjunction() + : traverse(fBody, Scope.lambda(joinFrom, sub, rel, fVar, + Scope.rebase(scope, correlated, sub))); boolean nonEmpty = ("gt".equals(op) && numValue == 0L) || ("ge".equals(op) && numValue == 1L); boolean empty = ("eq".equals(op) && numValue == 0L) @@ -602,14 +873,35 @@ private Predicate trySizeComparison(String op, List operands, Scope sco || ("lt".equals(op) && numValue == 1L); if (nonEmpty) { - return existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.conjunction()); + return existsSubquery(scope, rel, bodyBuilder); } if (empty) { - return negate(existsSubquery(scope, rel, (sub, joinFrom, correlated) -> cb.conjunction())); + return negate(existsSubquery(scope, rel, bodyBuilder)); } - throw new IllegalArgumentException( - "Unsupported size comparison: size(" + var + ") " + op + " " + numValue - + ". Only emptiness checks (size > 0, size == 0) are supported."); + // Arbitrary N → correlated (SELECT COUNT(...)) N, same shape as exists_one. + Subquery sub = scope.parentQuery().subquery(Long.class); + From correlated = correlate(sub, scope.from()); + Join joinFrom = correlated.join(rel.joinAttribute()); + sub.select(cb.count(joinFrom)); + if (fBody != null) { + sub.where(bodyBuilder.build(sub, joinFrom, correlated)); + } + return compareCount(sub, op, numValue); + } + + /** Compare a numeric size expression (COUNT subquery or LENGTH) against a constant. */ + private > Predicate compareCount( + jakarta.persistence.criteria.Expression count, String op, N n) { + return switch (op) { + case "eq" -> cb.equal(count, n); + case "ne" -> cb.notEqual(count, n); + case "lt" -> cb.lessThan(count, n); + case "gt" -> cb.greaterThan(count, n); + case "le" -> cb.lessThanOrEqualTo(count, n); + case "ge" -> cb.greaterThanOrEqualTo(count, n); + default -> throw new IllegalArgumentException( + "Unsupported size comparison operator: " + op); + }; } // -- exists / exists_one / all / except / filter -- diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java index 39ae9c7d..045d7d2f 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java @@ -40,7 +40,6 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -97,7 +96,11 @@ private record Seed(String id, boolean aBool, String aString, int aNumber, new Seed("a7", true, "tail\\", 0, "z", List.of(new Tag("t7a", "other")), List.of()), new Seed("a8", true, "", 2, null, - List.of(new Tag("t8a", "public")), List.of("tech")) + List.of(new Tag("t8a", "public")), List.of("tech")), + // Field-to-field witness: aString == aOptionalString == a tag name, so the + // field-to-field and lambda-field-to-field oracles are non-degenerate. + new Seed("a9", true, "same", 4, "same", + List.of(new Tag("t9a", "same")), List.of()) ); private static GenericContainer cerbos; @@ -244,6 +247,10 @@ private static List adapterFilteredIds(String action) { "outer-attr-depth2", "lambda-in-principal", "nary-and", "double-negation", "triple-negation", "not-empty", "optional-ne", + "lambda-field-to-field", "field-to-field", + "size-threshold", "size-filter-count", "string-size", + "ternary-cmp", "ternary-expr-cond", "ternary-nested", "ternary-negated", + "ternary-bare", "ternary-value-first", "ternary-null-cond", }) void adapterMatchesCheckOracle(String action) { List oracle = oracleAllowedIds(action); @@ -252,15 +259,6 @@ void adapterMatchesCheckOracle(String action) { "adapter result diverges from check-API oracle for action '" + action + "'"); } - @Test - void fieldToFieldInsideLambdaFailsLoudly() { - // Documented unsupported shape — must be a clear error, never a silently wrong result. - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> adapterFilteredIds("lambda-field-to-field")); - assertTrue(ex.getMessage().contains("Field-to-field"), - "expected the field-to-field guard, got: " + ex.getMessage()); - } - @Test void oracleIsNotDegenerate() { // Guard the guard: at least one action must produce a non-empty, non-total oracle set, diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 44518498..4634aa81 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -982,10 +982,11 @@ void notStartsWith() { } // -- CEL primitives (PR #223) -- - // Only `empty-collection` (size(coll) == 0) is natively supported via the existing emptiness - // path in trySizeComparison. Arithmetic, regex, casts, ternary, list indexing, and - // size() over a scalar string all throw — the Spring Data adapter has no shape for them in - // its Criteria-based predicate builder. + // `empty-collection` (size(coll) == 0) is natively supported via the existing emptiness + // path in trySizeComparison, and the CEL ternary (`if(cond, then, else)`) is rewritten into + // an OR of guarded branch predicates. Arithmetic, regex, casts, and list indexing still + // throw — the Spring Data adapter has no shape for them in its Criteria-based predicate + // builder. @Nested class CelPrimitives { @@ -1048,15 +1049,20 @@ void convertIntThrows() { } @Test - void ternaryThrows() { - // The CEL planner emits ternary as `if(cond, then, else)` — not `conditional`. - assertActionThrows("ternary", FIELD_MAP, "if()"); + void ternarySelectsThenBranchRows() { + // Policy: (R.attr.aBool ? R.attr.aNumber : 0) > 0 — the planner emits the ternary as + // if(cond, then, else); the adapter rewrites cmp(if(c,a,b), v) into + // OR(AND(c, a cmp v), AND(!c, b cmp v)). + // r1: aBool=true → then-branch: aNumber=1 > 0 → ✓ + // r2: aBool=false → else-branch: 0 > 0 → ✗ + // r3: aBool=true → then-branch: aNumber=3 > 0 → ✓ + assertEquals(List.of("1", "3"), run("ternary")); } @Test - void stringSizeThrows() { - // size(R.attr.aString) > 0 — adapter only handles size() on Relation mappings. - assertActionThrows("string-size", FIELD_MAP, "size()", "Relation"); + void stringSizeComparesLength() { + // size(R.attr.aString) > 0 → LENGTH(a_string) > 0; every row has a non-empty aString. + assertEquals(List.of("1", "2", "3"), run("string-size")); } } @@ -1072,9 +1078,17 @@ void isNotSet() { } @Test - void equalFieldToFieldThrows() { - // aString == id — adapter rejects two-variable comparisons with a specific message. - assertActionThrows("equal-field-to-field", FIELD_MAP, "Field-to-field", "eq"); + void equalFieldToField() { + // aString == id (mapped to oid) — no row's aString equals its oid. + assertEquals(List.of(), run("equal-field-to-field")); + // The ne direction is non-degenerate: every row's aString differs from its oid. + assertEquals(List.of("1", "2", "3"), run("not-equal-field-to-field")); + } + + @Test + void sizeCountThreshold() { + // size(ownedBy) >= 2 → COUNT subquery; only r1 has two owners. + assertEquals(List.of("1"), run("size-count-threshold")); } @Test @@ -1121,11 +1135,10 @@ void mapComparedToLiteralListThrows() { "map(...)", "hasIntersection"); } - // TODO(#232): trySizeComparison only accepts a Variable as size()'s operand, so - // `size(filter(...)) > 0` falls through and throws. @Test - void sizeOfFilterThrows() { - assertActionThrows("filter-count-gt", NESTED_FIELD_MAP, "size()"); + void sizeOfFilterCountsMatchingElements() { + // size(tags.filter(t, t.name == "public")) > 0 → r1 and r3 have a "public" tag. + assertEquals(List.of("1", "3"), runNested("filter-count-gt")); } } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index ea9c75b9..a90acc8d 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -362,14 +362,58 @@ void sizeEqZeroBuildsNotExists() { nval(0)))); } - @Test - void unsupportedSizeComparisonThrows() { - // Only emptiness checks (size > 0, size == 0) are supported; size > 5 must throw. - assertConditionThrows( - exprOp("gt", - exprOp("size", var("request.resource.attr.ownedBy")), - nval(5)), - "size", "Unsupported size comparison"); + // -- size(collection) compared with arbitrary N → correlated (SELECT COUNT(...)) N. + // Seeds a real row because an empty table cannot distinguish count thresholds. + + @Nested + class SizeCountComparisons { + + private ResourceEntity seeded() { + ResourceEntity r = new ResourceEntity("size-seed-1"); + r.setOwnedBy(new java.util.ArrayList<>(java.util.List.of("user1", "user2"))); + r.addTag("tagX", "x"); + return r; + } + + @Test + void sizeComparedWithArbitraryN() { + // Seeded row has 2 owners (@ElementCollection) and 1 tag (@OneToMany). + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("eq", + exprOp("size", var("request.resource.attr.ownedBy")), nval(2)))); + assertEquals(0, runCount(exprOp("eq", + exprOp("size", var("request.resource.attr.ownedBy")), nval(3)))); + assertEquals(1, runCount(exprOp("gt", + exprOp("size", var("request.resource.attr.ownedBy")), nval(1)))); + assertEquals(0, runCount(exprOp("gt", + exprOp("size", var("request.resource.attr.ownedBy")), nval(2)))); + assertEquals(1, runCount(exprOp("le", + exprOp("size", var("request.resource.attr.ownedBy")), nval(2)))); + assertEquals(0, runCount(exprOp("lt", + exprOp("size", var("request.resource.attr.ownedBy")), nval(2)))); + assertEquals(0, runCount(exprOp("ge", + exprOp("size", var("request.resource.attr.ownedBy")), nval(3)))); + assertEquals(0, runCount(exprOp("ne", + exprOp("size", var("request.resource.attr.ownedBy")), nval(2)))); + // Entity relation (@OneToMany), not just element collections. + assertEquals(1, runCount(exprOp("eq", + exprOp("size", var("request.resource.attr.tags")), nval(1)))); + }); + } + + @Test + void sizeValueFirstWithArbitraryNIsMirrored() { + // 3 > size(ownedBy) → size < 3, with 2 owners → match. The naive (unmirrored) + // translation `size > 3` would return 0. + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("gt", + nval(3), + exprOp("size", var("request.resource.attr.ownedBy"))))); + assertEquals(1, runCount(exprOp("lt", + nval(1), + exprOp("size", var("request.resource.attr.ownedBy"))))); + }); + } } @Test @@ -728,23 +772,25 @@ void convertIntThrows() { } @Test - void ternaryThrows() { - // The CEL planner emits ternary as `if(cond, then, else)` in the AST. - Operand ternary = exprOp("if", - var("request.resource.attr.aBool"), - var("request.resource.attr.aNumber"), - nval(0)); - assertConditionThrows(exprOp("gt", ternary, nval(0)), "if()"); - } - - @Test - void stringSizeThrows() { - // size(aString) > 0 — size() requires a Relation mapping; aString is a Field. - assertConditionThrows( - exprOp("gt", - exprOp("size", var("request.resource.attr.aString")), - nval(0)), - "size()", "Relation"); + void stringSizeComparesLength() { + // size(aString) on a Field mapping → LENGTH(a_string) N. + // Seeded aString = "seededString" (12 chars). + ResourceEntity r = new ResourceEntity("string-size-seed-1"); + r.setaString("seededString"); + withResource(r, () -> { + assertEquals(1, runCount(exprOp("eq", + exprOp("size", var("request.resource.attr.aString")), nval(12)))); + assertEquals(0, runCount(exprOp("eq", + exprOp("size", var("request.resource.attr.aString")), nval(5)))); + assertEquals(1, runCount(exprOp("gt", + exprOp("size", var("request.resource.attr.aString")), nval(0)))); + assertEquals(0, runCount(exprOp("gt", + exprOp("size", var("request.resource.attr.aString")), nval(20)))); + // Value-first is mirrored: 5 < size(aString) → length > 5 → match. + assertEquals(1, runCount(exprOp("lt", + nval(5), + exprOp("size", var("request.resource.attr.aString"))))); + }); } } @@ -761,13 +807,61 @@ void isNotSetBuildsIsNull() { } @Test - void equalFieldToFieldThrows() { - // eq(var, var) — adapter rejects two-variable comparisons with a specific message. + void fieldToFieldEquality() { + // eq/ne over two variables compares the two columns directly. + // Seeded: aString == createdBy == "same"; aOptionalString differs. + ResourceEntity r = new ResourceEntity("f2f-seed-1"); + r.setaString("same"); + r.setCreatedBy("same"); + r.setaOptionalString("different"); + withResource(r, () -> { + assertEquals(1, runCount(exprOp("eq", + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy")))); + assertEquals(0, runCount(exprOp("eq", + var("request.resource.attr.aString"), + var("request.resource.attr.aOptionalString")))); + assertEquals(0, runCount(exprOp("ne", + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy")))); + assertEquals(1, runCount(exprOp("ne", + var("request.resource.attr.aString"), + var("request.resource.attr.aOptionalString")))); + }); + } + + @Test + void fieldToFieldOrderingKeepsOperandDirection() { + // lt/gt over two variables must honor source order: createdBy < aString + // with createdBy = "abc", aString = "xyz" → match; the swapped form must not. + ResourceEntity r = new ResourceEntity("f2f-seed-2"); + r.setaString("xyz"); + r.setCreatedBy("abc"); + r.setaNumber(5); + withResource(r, () -> { + assertEquals(1, runCount(exprOp("lt", + var("request.resource.attr.createdBy"), + var("request.resource.attr.aString")))); + assertEquals(0, runCount(exprOp("lt", + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy")))); + assertEquals(1, runCount(exprOp("le", + var("request.resource.attr.aNumber"), + var("request.resource.attr.aNumber")))); + assertEquals(0, runCount(exprOp("gt", + var("request.resource.attr.aNumber"), + var("request.resource.attr.aNumber")))); + }); + } + + @Test + void fieldToFieldNonComparisonOperatorStillThrows() { + // contains(var, var) has no portable JPA translation — the specific message stays. assertConditionThrows( - exprOp("eq", + exprOp("contains", var("request.resource.attr.aString"), var("request.resource.attr.createdBy")), - "Field-to-field", "eq"); + "Field-to-field", "contains"); } @Test @@ -822,14 +916,27 @@ void mapComparedToLiteralListThrows() { } @Test - void sizeOfFilterThrows() { - // size(tags.filter(t, t.name == "public")) > 0 — size() operand must be a variable. + void sizeOfFilterCountsMatchingElements() { + // size(tags.filter(t, t.name == "public")) N → correlated + // (SELECT COUNT(...) WHERE lambda) N. Seeded row: tags [public, public, x]. + ResourceEntity r = new ResourceEntity("size-filter-seed-1"); + r.addTag("tagA", "public"); + r.addTag("tagB", "public"); + r.addTag("tagC", "x"); Operand filterExpr = exprOp("filter", var("request.resource.attr.tags"), lambda("t", exprOp("eq", var("t.name"), sval("public")))); - assertConditionThrows( - exprOp("gt", exprOp("size", filterExpr), nval(0)), - "size()"); + withResource(r, () -> { + assertEquals(1, runCount(exprOp("eq", exprOp("size", filterExpr), nval(2)))); + assertEquals(0, runCount(exprOp("eq", exprOp("size", filterExpr), nval(3)))); + assertEquals(1, runCount(exprOp("gt", exprOp("size", filterExpr), nval(1)))); + assertEquals(0, runCount(exprOp("gt", exprOp("size", filterExpr), nval(2)))); + // Emptiness checks work through the same path. + assertEquals(1, runCount(exprOp("gt", exprOp("size", filterExpr), nval(0)))); + assertEquals(0, runCount(exprOp("eq", exprOp("size", filterExpr), nval(0)))); + // Value-first is mirrored: 3 > size(filter) → count < 3 → match. + assertEquals(1, runCount(exprOp("gt", nval(3), exprOp("size", filterExpr)))); + }); } } @@ -1052,6 +1159,337 @@ void overrideIsConsultedUnderMirroredOperator() { } } + // -- CEL ternary (PR: ternary support): `if(cond, then, else)` is rewritten into pure + // predicates — cmp(if(c,a,b), other) → (c AND cmp(a, other)) OR (NOT c AND cmp(b, other)). + // Seeds real rows because an empty table cannot distinguish the branch predicates. + + @Nested + class TernaryIfExpressions { + + /** gt(if(aBool, aNumber, 0), 0) — the canonical `(R.attr.aBool ? R.attr.aNumber : 0) > 0`. */ + private Operand canonicalPlan() { + return exprOp("gt", + exprOp("if", + var("request.resource.attr.aBool"), + var("request.resource.attr.aNumber"), + nval(0)), + nval(0)); + } + + @Test + void comparisonWrappingTernary() { + // aBool = true → then-branch compares aNumber > 0. + ResourceEntity match = new ResourceEntity("ternary-seed-1"); + match.setaBool(true); + match.setaNumber(10); + withResource(match, () -> assertEquals(1, runCount(canonicalPlan()))); + + ResourceEntity zeroThen = new ResourceEntity("ternary-seed-2"); + zeroThen.setaBool(true); + zeroThen.setaNumber(0); + withResource(zeroThen, () -> assertEquals(0, runCount(canonicalPlan()))); + + // aBool = false → else branch folds to gt(0, 0) → always false, whatever aNumber is. + ResourceEntity elseBranch = new ResourceEntity("ternary-seed-3"); + elseBranch.setaBool(false); + elseBranch.setaNumber(10); + withResource(elseBranch, () -> assertEquals(0, runCount(canonicalPlan()))); + } + + @Test + void bareBooleanTernary() { + // aBool ? aString == "x" : aNumber > 5 — the ternary IS the condition, both + // branches are boolean expressions. + Operand plan = exprOp("if", + var("request.resource.attr.aBool"), + exprOp("eq", var("request.resource.attr.aString"), sval("x")), + exprOp("gt", var("request.resource.attr.aNumber"), nval(5))); + + ResourceEntity thenMatch = new ResourceEntity("ternary-bare-1"); + thenMatch.setaBool(true); + thenMatch.setaString("x"); + thenMatch.setaNumber(0); + withResource(thenMatch, () -> assertEquals(1, runCount(plan))); + + // then-branch active but not satisfied — the else branch must NOT rescue the row. + ResourceEntity thenMiss = new ResourceEntity("ternary-bare-2"); + thenMiss.setaBool(true); + thenMiss.setaString("y"); + thenMiss.setaNumber(10); + withResource(thenMiss, () -> assertEquals(0, runCount(plan))); + + ResourceEntity elseMatch = new ResourceEntity("ternary-bare-3"); + elseMatch.setaBool(false); + elseMatch.setaString("x"); + elseMatch.setaNumber(10); + withResource(elseMatch, () -> assertEquals(1, runCount(plan))); + + ResourceEntity elseMiss = new ResourceEntity("ternary-bare-4"); + elseMiss.setaBool(false); + elseMiss.setaString("x"); + elseMiss.setaNumber(1); + withResource(elseMiss, () -> assertEquals(0, runCount(plan))); + } + + @Test + void bareBooleanTernaryWithConstantBranch() { + // aBool ? true : aNumber > 5 — a boolean VALUE branch folds to 1=1 / 1=0. + Operand plan = exprOp("if", + var("request.resource.attr.aBool"), + bval(true), + exprOp("gt", var("request.resource.attr.aNumber"), nval(5))); + + ResourceEntity thenMatch = new ResourceEntity("ternary-bare-const-1"); + thenMatch.setaBool(true); + thenMatch.setaNumber(0); + withResource(thenMatch, () -> assertEquals(1, runCount(plan))); + + ResourceEntity elseMiss = new ResourceEntity("ternary-bare-const-2"); + elseMiss.setaBool(false); + elseMiss.setaNumber(1); + withResource(elseMiss, () -> assertEquals(0, runCount(plan))); + + // aBool ? false : aNumber > 5 — a false then-branch excludes matching-condition rows. + Operand planFalse = exprOp("if", + var("request.resource.attr.aBool"), + bval(false), + exprOp("gt", var("request.resource.attr.aNumber"), nval(5))); + ResourceEntity falseThen = new ResourceEntity("ternary-bare-const-3"); + falseThen.setaBool(true); + falseThen.setaNumber(10); + withResource(falseThen, () -> assertEquals(0, runCount(planFalse))); + } + + @Test + void valueFirstComparisonIsMirrored() { + // 3 < (aBool ? aNumber : 0) — planner preserves source order, so the constant sits + // on the LEFT. Branch substitution keeps positions, and NormalizedBinary mirrors the + // recursed comparisons: lt(3, aNumber) → aNumber > 3. + Operand plan = exprOp("lt", + nval(3), + exprOp("if", + var("request.resource.attr.aBool"), + var("request.resource.attr.aNumber"), + nval(0))); + + ResourceEntity match = new ResourceEntity("ternary-mirror-1"); + match.setaBool(true); + match.setaNumber(5); + withResource(match, () -> assertEquals(1, runCount(plan))); + + // Naive (unmirrored) translation `aNumber < 3` would wrongly match this row. + ResourceEntity below = new ResourceEntity("ternary-mirror-2"); + below.setaBool(true); + below.setaNumber(2); + withResource(below, () -> assertEquals(0, runCount(plan))); + + // else branch: 3 < 0 folds to always-false — aNumber must not leak in. + ResourceEntity elseRow = new ResourceEntity("ternary-mirror-3"); + elseRow.setaBool(false); + elseRow.setaNumber(99); + withResource(elseRow, () -> assertEquals(0, runCount(plan))); + } + + @Test + void nestedTernaryInBranch() { + // (aBool ? (aString == "x" ? aNumber : 1) : 0) > 2 — the then-branch is itself a + // ternary; substitution recurses until no `if` remains. + Operand plan = exprOp("gt", + exprOp("if", + var("request.resource.attr.aBool"), + exprOp("if", + exprOp("eq", var("request.resource.attr.aString"), sval("x")), + var("request.resource.attr.aNumber"), + nval(1)), + nval(0)), + nval(2)); + + ResourceEntity innerThen = new ResourceEntity("ternary-nested-1"); + innerThen.setaBool(true); + innerThen.setaString("x"); + innerThen.setaNumber(5); + withResource(innerThen, () -> assertEquals(1, runCount(plan))); + + // Inner else: 1 > 2 → always false, even though aNumber would match. + ResourceEntity innerElse = new ResourceEntity("ternary-nested-2"); + innerElse.setaBool(true); + innerElse.setaString("y"); + innerElse.setaNumber(5); + withResource(innerElse, () -> assertEquals(0, runCount(plan))); + + // Outer else: 0 > 2 → always false. + ResourceEntity outerElse = new ResourceEntity("ternary-nested-3"); + outerElse.setaBool(false); + outerElse.setaString("x"); + outerElse.setaNumber(5); + withResource(outerElse, () -> assertEquals(0, runCount(plan))); + } + + @Test + void ternaryUnderLogicalOperators() { + // The rewrite produces an OR-of-ANDs; it must compose under not/and/or like any + // other predicate (negation goes through the junction-barrier helper). + Operand comparison = exprOp("gt", + exprOp("if", + var("request.resource.attr.aBool"), + var("request.resource.attr.aNumber"), + nval(0)), + nval(0)); + + ResourceEntity truthy = new ResourceEntity("ternary-logic-1"); + truthy.setaBool(true); + truthy.setaString("x"); + truthy.setaNumber(10); + withResource(truthy, () -> { + assertEquals(0, runCount(exprOp("not", comparison))); + // Double negation must toggle back (junction barrier, not raw cb.not). + assertEquals(1, runCount(exprOp("not", exprOp("not", comparison)))); + assertEquals(1, runCount(exprOp("and", comparison, + exprOp("eq", var("request.resource.attr.aString"), sval("x"))))); + assertEquals(0, runCount(exprOp("and", comparison, + exprOp("eq", var("request.resource.attr.aString"), sval("z"))))); + assertEquals(1, runCount(exprOp("or", comparison, + exprOp("eq", var("request.resource.attr.aString"), sval("z"))))); + }); + + // Row where the ternary comparison is false: NOT must select it, OR must rescue it + // only through the other arm. + ResourceEntity falsy = new ResourceEntity("ternary-logic-2"); + falsy.setaBool(false); + falsy.setaString("x"); + falsy.setaNumber(10); + withResource(falsy, () -> { + assertEquals(1, runCount(exprOp("not", comparison))); + assertEquals(0, runCount(exprOp("not", exprOp("not", comparison)))); + assertEquals(1, runCount(exprOp("or", comparison, + exprOp("eq", var("request.resource.attr.aString"), sval("x"))))); + assertEquals(0, runCount(exprOp("or", comparison, + exprOp("eq", var("request.resource.attr.aString"), sval("z"))))); + }); + } + + @Test + void ternaryWithExpressionCondition() { + // (aNumber > 5 ? aString : "none") == "x" — condition is a boolean EXPRESSION, + // not a bare variable. + Operand plan = exprOp("eq", + exprOp("if", + exprOp("gt", var("request.resource.attr.aNumber"), nval(5)), + var("request.resource.attr.aString"), + sval("none")), + sval("x")); + + ResourceEntity condTrue = new ResourceEntity("ternary-exprcond-1"); + condTrue.setaNumber(10); + condTrue.setaString("x"); + withResource(condTrue, () -> assertEquals(1, runCount(plan))); + + // Condition false → eq("none", "x") folds to always-false. + ResourceEntity condFalse = new ResourceEntity("ternary-exprcond-2"); + condFalse.setaNumber(1); + condFalse.setaString("x"); + withResource(condFalse, () -> assertEquals(0, runCount(plan))); + } + + @Test + void eqNeWithTernary() { + // (aBool ? aString : "none") == "x" / != "x" + Operand ternary = exprOp("if", + var("request.resource.attr.aBool"), + var("request.resource.attr.aString"), + sval("none")); + Operand eqPlan = exprOp("eq", ternary, sval("x")); + Operand nePlan = exprOp("ne", ternary, sval("x")); + + ResourceEntity thenX = new ResourceEntity("ternary-eqne-1"); + thenX.setaBool(true); + thenX.setaString("x"); + withResource(thenX, () -> { + assertEquals(1, runCount(eqPlan)); + assertEquals(0, runCount(nePlan)); + }); + + ResourceEntity thenY = new ResourceEntity("ternary-eqne-2"); + thenY.setaBool(true); + thenY.setaString("y"); + withResource(thenY, () -> { + assertEquals(0, runCount(eqPlan)); + assertEquals(1, runCount(nePlan)); + }); + + // else branch folds: eq("none", "x") → always false; ne("none", "x") → always true. + ResourceEntity elseRow = new ResourceEntity("ternary-eqne-3"); + elseRow.setaBool(false); + elseRow.setaString("x"); + withResource(elseRow, () -> { + assertEquals(0, runCount(eqPlan)); + assertEquals(1, runCount(nePlan)); + }); + } + + @Test + void constantVersusConstantComparisonsFold() { + // (aBool ? 1 : 0) > 0 — BOTH branches collapse to constant comparisons, leaving + // only the condition predicate. Seeded row: matches iff aBool is true. + Operand allConstBranches = exprOp("gt", + exprOp("if", var("request.resource.attr.aBool"), nval(1), nval(0)), + nval(0)); + + ResourceEntity boolTrue = new ResourceEntity("ternary-const-1"); + boolTrue.setaBool(true); + withResource(boolTrue, () -> { + assertEquals(1, runCount(allConstBranches)); + + // Direct value-vs-value plans exercise the fold through the public seam: + // numbers compare in double space (1.0 == 1, 0.5 < 1), strings via compareTo, + // mixed incomparable types are eq → false / ne → true. + assertEquals(1, runCount(exprOp("eq", nval(1.0), nval(1)))); + assertEquals(1, runCount(exprOp("lt", nval(0.5), nval(1)))); + assertEquals(0, runCount(exprOp("gt", nval(0), nval(0)))); + assertEquals(1, runCount(exprOp("ge", nval(2), nval(2)))); + assertEquals(1, runCount(exprOp("lt", sval("a"), sval("b")))); + assertEquals(0, runCount(exprOp("eq", sval("a"), nval(1)))); + assertEquals(1, runCount(exprOp("ne", sval("a"), nval(1)))); + assertEquals(1, runCount(exprOp("eq", bval(true), bval(true)))); + // Ordering incomparable constant types is a planner bug and must throw. + assertConditionThrows(exprOp("lt", sval("a"), nval(1)), + "Cannot order", "lt"); + }); + + ResourceEntity boolFalse = new ResourceEntity("ternary-const-2"); + boolFalse.setaBool(false); + withResource(boolFalse, () -> assertEquals(0, runCount(allConstBranches))); + } + + @Test + void ternaryWithWrongOperandCountThrows() { + // if() with 2 operands inside a comparison — malformed plan, not a silent drop. + assertConditionThrows( + exprOp("gt", + exprOp("if", var("request.resource.attr.aBool"), nval(1)), + nval(0)), + "if (ternary) requires exactly 3 operands", "got 2"); + // Same contract for a bare-boolean-position ternary. + assertConditionThrows( + exprOp("if", var("request.resource.attr.aBool"), bval(true)), + "if (ternary) requires exactly 3 operands", "got 2"); + } + + @Test + void ternaryUnderUnsupportedWrapperNamesOperator() { + // contains(if(...), "x") — only eq/ne/lt/gt/le/ge accept a ternary operand; the + // error must name the offending wrapper operator. + assertConditionThrows( + exprOp("contains", + exprOp("if", + var("request.resource.attr.aBool"), + var("request.resource.attr.aString"), + sval("none")), + sval("x")), + "if()", "contains"); + } + } + // -- Lambda bodies referencing outer (non-lambda) resource attributes -- @Nested diff --git a/spring-data/src/test/resources/adversarial-policy.yaml b/spring-data/src/test/resources/adversarial-policy.yaml index 078245cf..39a9ec7a 100644 --- a/spring-data/src/test/resources/adversarial-policy.yaml +++ b/spring-data/src/test/resources/adversarial-policy.yaml @@ -185,10 +185,101 @@ resourcePolicy: match: expr: R.attr.aOptionalString != "x" - # -- documented unsupported shape: field-to-field inside a lambda must throw cleanly -- + # -- field-to-field comparison inside a lambda (join column vs correlated outer column) -- - actions: ["lambda-field-to-field"] effect: EFFECT_ALLOW roles: ["USER"] condition: match: expr: R.attr.tags.exists(t, t.name == R.attr.aString) + + # -- size(coll) compared with a non-zero threshold → correlated COUNT subquery -- + - actions: ["size-threshold"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: size(R.attr.tags) > 1 + + # -- size over a filtered collection → COUNT subquery with the lambda as WHERE -- + - actions: ["size-filter-count"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: size(R.attr.tags.filter(t, t.name == "public")) == 1 + + # -- size(string) → LENGTH(column). Threshold chosen so the astral-char seed ("héllo🚀", + # 6 code points in CEL vs 7 UTF-16 units in the DB) lands on the same side either way. + - actions: ["string-size"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: size(R.attr.aString) > 4 + + # -- top-level field-to-field: column compared to column, incl. NULL exclusion semantics -- + - actions: ["field-to-field"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString == R.attr.aOptionalString + + # -- CEL ternary (planner emits if(cond, then, else)) -- + # Canonical comparison-wrapped ternary: cmp(if(c,a,b), v) rewritten into guarded branches. + - actions: ["ternary-cmp"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '(R.attr.aBool ? R.attr.aNumber : 0) > 1' + + # Ternary whose condition is itself an expression (startsWith), not a bare bool column. + - actions: ["ternary-expr-cond"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '(R.attr.aString.startsWith("100") ? R.attr.aNumber : -1) >= 0' + + # Nested ternary: the then-branch is another ternary; empty-string seed lands in the inner else. + - actions: ["ternary-nested"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '(R.attr.aBool ? (R.attr.aString == "" ? 0 : R.attr.aNumber) : -1) >= 2' + + # Ternary under !(...): NOT over the OR-of-guarded-branches rewrite. + - actions: ["ternary-negated"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!((R.attr.aBool ? R.attr.aNumber : 0) > 1)' + + # Bare boolean-result ternary: both branches are predicates, no wrapping comparison. + - actions: ["ternary-bare"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aBool ? R.attr.aString == "one" : R.attr.aNumber < 0' + + # Value-first form: constant on the left of the comparison wrapping the ternary. + - actions: ["ternary-value-first"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '0 < (R.attr.aBool ? R.attr.aNumber : -1)' + + # NULL condition column (aOptionalString is NULL on a2/a4/a8): CEL missing-attr error (deny) + # must align with SQL three-valued logic excluding the row from BOTH rewritten branches. + - actions: ["ternary-null-cond"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '(R.attr.aOptionalString != "x" ? R.attr.aNumber : 0) > 1' From a6d1dee718034b1a3c46ebb57b0e8ce182fa7651 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 20 Jul 2026 17:05:10 +0100 Subject: [PATCH 15/20] feat(spring-data): arithmetic comparisons and field-to-field string matching - add/sub/mult/div inside eq/ne/lt/gt/le/ge -> cb.sum/diff/prod/quot compared in double space; nested, value-first, and both-sides shapes compose. Empirical PDP probe: Cerbos attribute numbers are CEL doubles, so int-literal arithmetic errors at check time and / is true double division -- double space is the only check-consistent semantics (wire plan is identical for 1 vs 1.0) - mod stays unsupported by semantics: CEL % is int-only and can never evaluate over attribute doubles; SQL MOD would fabricate rows the PDP always denies - field-to-field contains/startsWith/endsWith -> LIKE over a needle column escaped via nested REPLACE (\, %, _) with an IS NOT NULL needle guard (CEL missing-attr -> deny; also defends NULL-ignoring CONCAT dialects) - 10 new adversarial differential actions (arith-*, f2f-*) incl. hostile needle seeds containing % _ \ and a div-truncation probe; 275 tests green - README: rows moved to supported; gotchas on double-literal policy arithmetic and REPLACE-based pattern escaping Signed-off-by: Alex Olivier --- spring-data/README.md | 26 +- .../SpringDataQueryPlanAdapter.java | 193 ++++++++++++- .../AdversarialConformanceTest.java | 12 +- .../springdata/SpringDataIntegrationTest.java | 28 +- .../SpringDataQueryPlanAdapterTest.java | 272 +++++++++++++++--- .../test/resources/adversarial-policy.yaml | 84 ++++++ 6 files changed, 558 insertions(+), 57 deletions(-) diff --git a/spring-data/README.md b/spring-data/README.md index 19576fee..56320ba2 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -119,6 +119,8 @@ Map each `request.resource.attr.` to a JPA path or a relation: | `size(string)` | `cb.length(column)` (see Gotchas for astral-character caveat) | | Field-to-field (`R.attr.a == R.attr.b`) | `cb.equal(pathA, pathB)` and friends for `eq`/`ne`/`lt`/`gt`/`le`/`ge`, incl. inside lambdas | | Ternary (`cond ? a : b`) | Predicate rewrite: `(cond AND cmp(a, v)) OR (NOT cond AND cmp(b, v))` — nested, boolean-position, and value-first forms compose; constant residues fold (see Gotchas for `NULL` semantics) | +| Arithmetic in comparisons (`add`/`sub`/`mult`/`div`) | `cb.sum`/`diff`/`prod`/`quot` in double space, compared via `eq`/`ne`/`lt`/`gt`/`le`/`ge`; nested and both-sides shapes compose (see Gotchas — CEL attribute arithmetic is double arithmetic) | +| Field-to-field `contains`/`startsWith`/`endsWith` | `cb.like` over a `REPLACE`-escaped column-derived pattern (`\`, `%`, `_`), with an `IS NOT NULL` needle guard | | `exists(coll, lambda)` | Correlated `EXISTS` with lambda body | | `exists_one(coll, lambda)` | Correlated `(SELECT COUNT...) = 1` | | `all(coll, lambda)` | `NOT EXISTS (... AND NOT(body))` | @@ -151,11 +153,11 @@ SQL fragments), or wait for adapter support. | Construct | Example CEL | Notes | |-------------------------------------------------|---------------------------------------------------|-------| -| Arithmetic (`add`/`sub`/`mult`/`div`/`mod`) | `R.attr.aNumber + 1 > 2` | `add` is supported only as constant folding inside `eq`/`ne`; other arithmetic on document fields requires a column-expression engine the Criteria API doesn't expose. | +| `mod` | `R.attr.aNumber % 2 == 0` | CEL `%` is int-only and Cerbos attribute numbers are doubles, so `%` on an attribute always errors → the check API denies every row; translating to SQL `MOD` would fabricate matches. | +| Arithmetic on non-numeric operands | `R.attr.aString + "x" < "y"` | Ordering through string concatenation is not translated; `add` string folding remains `eq`/`ne`-only. | | Regex match | `R.attr.aString.matches("^foo.*")` | JPA has no portable regex predicate; override per-dialect (`regexp_like`, `~`, `REGEXP`). | | List indexing | `R.attr.tags[0] == "x"` | JPA collections are unordered sets — no positional access. | | Type casts (`int(...)` / `double(...)` / `string(...)`) | `int(R.attr.aString) > 0` | No portable `CAST` in Criteria; override per-dialect. | -| Field-to-field `contains`/`startsWith`/`endsWith` | `R.attr.aString.contains(R.attr.createdBy)` | Comparison operators support field-to-field; LIKE against a column-derived pattern has no portable escaping. | | `eq(map(...), [...])` | `R.attr.tags.map(t, t.id) == ["tag1", "tag2"]` | Use `hasIntersection(map(...), [...])` instead. | ## Gotchas @@ -174,6 +176,26 @@ lengths near those values, rows can be filtered differently than a `check` call decide. Keep length thresholds away from values that straddle the difference, or avoid `size(string)` in policies over data that contains astral characters. +### Attribute arithmetic is double arithmetic — use double literals in policies + +Cerbos attribute values arrive as protobuf numbers, which CEL treats as doubles. +CEL arithmetic has no int/double cross-type overloads, so `R.attr.aNumber + 1` +(int literal) is an evaluation error at `check` time — every row is denied — while +`R.attr.aNumber + 1.0` evaluates normally. The planner erases the distinction (both +forms produce an identical wire plan), so the adapter translates the double reading, +the only satisfiable one: `/` is true double division (`5 / 2.0 == 2.5`), never +integer truncation. Write double literals (`1.0`, `2.0`) in policy arithmetic over +attributes, or the plan-based filter and per-resource `check` calls will disagree. + +### Field-to-field string matching builds its pattern with `REPLACE` + +`R.attr.a.contains(R.attr.b)` becomes `a LIKE CONCAT('%', , '%') ESCAPE '\'` +where `b` is escaped via nested `REPLACE` calls (`\`, `%`, `_`) — chosen because +`REPLACE` is available on H2, PostgreSQL, MySQL, Oracle, and SQL Server. A `NULL` +needle column excludes the row (`IS NOT NULL` guard), which matches CEL +missing-attribute → deny and also defends against dialects whose `CONCAT` treats +`NULL` as `''` (which would otherwise turn the pattern into match-everything `'%%'`). + ### Ternary with a `NULL` condition column excludes the row A comparison wrapping a ternary is rewritten as diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index c1b304e5..bee54b2e 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -149,6 +149,10 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, if (sizePred != null) { yield sizePred; } + Predicate arithPred = tryArithmeticComparison(nb.op(), nb.operands(), scope); + if (arithPred != null) { + yield arithPred; + } yield handleLeafOperator(nb.op(), nb.operands(), scope); } }; @@ -480,10 +484,9 @@ private static String typeName(Object o) { } /** - * Compare two mapped columns directly (eq/ne/lt/gt/le/ge). Operand source order is - * preserved — two variables rank equally, so {@link NormalizedBinary} never swaps them. - * Non-comparison operators (contains/startsWith/endsWith) have no portable JPA - * column-to-column translation and still throw. + * Compare two mapped columns directly (eq/ne/lt/gt/le/ge) or pattern-match one column + * against another (contains/startsWith/endsWith). Operand source order is preserved — + * two variables rank equally, so {@link NormalizedBinary} never swaps them. */ @SuppressWarnings({"rawtypes", "unchecked"}) private Predicate fieldToFieldComparison(String op, String leftVar, String rightVar, @@ -497,12 +500,50 @@ private Predicate fieldToFieldComparison(String op, String leftVar, String right case "gt" -> cb.greaterThan(left, right); case "le" -> cb.lessThanOrEqualTo(left, right); case "ge" -> cb.greaterThanOrEqualTo(left, right); + case "contains" -> fieldToFieldLike(left, right, true, true); + case "startsWith" -> fieldToFieldLike(left, right, false, true); + case "endsWith" -> fieldToFieldLike(left, right, true, false); default -> throw new IllegalArgumentException( "Field-to-field comparison is not supported for operator '" + op + "': " + leftVar + " vs " + rightVar); }; } + /** + * {@code haystackColumn LIKE wildcards(escape(needleColumn))} — the column-to-column + * analogue of the constant LIKE path in {@link #defaultLeaf}. The needle is data, so its + * LIKE metacharacters are escaped dynamically with nested {@code REPLACE} (portable: + * H2/Postgres/MySQL/Oracle/SQL Server): {@code \} first, then {@code %} and {@code _}, + * mirroring {@link PlanValues#escapeLike} and the same explicit {@code '\'} escape char. + * + *

The explicit {@code IS NOT NULL} guard on the needle matches CEL (a missing + * attribute is an evaluation error → deny) and also defends against dialects whose + * {@code CONCAT} treats NULL as {@code ''}, which would otherwise turn a NULL needle + * into a match-anything {@code '%%'} pattern. + */ + private Predicate fieldToFieldLike(jakarta.persistence.criteria.Expression haystack, + jakarta.persistence.criteria.Expression needle, + boolean leadingWildcard, boolean trailingWildcard) { + jakarta.persistence.criteria.Expression escaped = + needle.as(String.class); + escaped = cb.function("replace", String.class, + escaped, cb.literal("\\"), cb.literal("\\\\")); + escaped = cb.function("replace", String.class, + escaped, cb.literal("%"), cb.literal("\\%")); + escaped = cb.function("replace", String.class, + escaped, cb.literal("_"), cb.literal("\\_")); + jakarta.persistence.criteria.Expression pattern = escaped; + if (leadingWildcard) { + pattern = cb.concat(cb.literal("%"), pattern); + } + if (trailingWildcard) { + pattern = cb.concat(pattern, cb.literal("%")); + } + return cb.and( + cb.isNotNull(needle), + cb.like(haystack.as(String.class), pattern, '\\')); + } + /** * Apply a scalar leaf operator, consulting the per-operator {@code overrides} hook first so a * registered {@link OperatorFunction} wins on EVERY path that produces this operator — direct @@ -541,6 +582,150 @@ private Predicate defaultLeaf(String op, Path path, Object value) { }; } + // -- arithmetic (add/sub/mult/div) as a comparison operand -- + + /** CEL arithmetic operators that can appear as an operand of a comparison. */ + private static final Set ARITHMETIC_OPS = Set.of("add", "sub", "mult", "div", "mod"); + + /** + * Translate {@code cmp(arith(...), other)} — e.g. {@code R.attr.aNumber + 1.0 > 2.0} + * arriving as {@code gt(add(variable, value(1)), value(2))} — by emitting the arithmetic + * on the SQL side ({@code cb.sum}/{@code diff}/{@code prod}/{@code quot}) and comparing. + * + *

Everything is computed and compared in DOUBLE space. This is not a convenience: + * Cerbos attribute values are protobuf {@code Value} numbers, i.e. ALWAYS CEL doubles at + * check time, so the only arithmetic that can evaluate without a no-overload error is + * double-typed — verified against a live PDP: {@code R.attr.n + 1} (int literal) denies + * every row, {@code + 1.0} works, and {@code / 2.0} is true double division + * ({@code 5 / 2.0 == 2.5}). Integer truncation is therefore never observable through the + * check API, and the wire plan erases the int/double distinction anyway (both arrive as + * {@code number_value}). Emitting the arithmetic (rather than solving algebraically) + * also means multiplication/division by negative constants needs no inequality flipping. + * + *

{@code mod} stays unsupported: CEL {@code %} has no double overload, so on + * attribute values it always errors (deny) — translating it to SQL {@code MOD} would + * fabricate rows the PDP denies. + * + * @return the predicate, or {@code null} if this comparison involves no arithmetic + * expression or the shape is owned by the {@code add} fold/solve path (which + * also handles string concatenation and the override hooks) + */ + private Predicate tryArithmeticComparison(String op, List operands, Scope scope) { + if (!TERNARY_COMPARISONS.contains(op) || operands.size() != 2) { + return null; + } + boolean hasArith = operands.stream().anyMatch(o -> + o.getNodeCase() == Operand.NodeCase.EXPRESSION + && ARITHMETIC_OPS.contains(o.getExpression().getOperator())); + if (!hasArith) { + return null; + } + if (addFoldSolveOwns(op, operands.get(0), operands.get(1)) + || addFoldSolveOwns(op, operands.get(1), operands.get(0))) { + return null; + } + jakarta.persistence.criteria.Expression left = + resolveNumericExpression(operands.get(0), scope); + jakarta.persistence.criteria.Expression right = + resolveNumericExpression(operands.get(1), scope); + return switch (op) { + case "eq" -> cb.equal(left, right); + case "ne" -> cb.notEqual(left, right); + case "lt" -> cb.lt(left, right); + case "gt" -> cb.gt(left, right); + case "le" -> cb.le(left, right); + case "ge" -> cb.ge(left, right); + default -> throw new IllegalArgumentException( + "Unsupported arithmetic comparison operator: " + op); + }; + } + + /** + * Whether {@code cmp(candidate, other)} is a shape {@link #handleAddComparison} already + * translates — those keep their existing path (constant folding, string concat + * solving, and the {@link OperatorFunction} override hooks): {@code add(value, value)} + * against a field for any operator, and {@code add} of one field and one value against + * a value for eq/ne. + */ + private static boolean addFoldSolveOwns(String op, Operand candidate, Operand other) { + if (candidate.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"add".equals(candidate.getExpression().getOperator()) + || candidate.getExpression().getOperandsCount() != 2) { + return false; + } + Operand l = candidate.getExpression().getOperands(0); + Operand r = candidate.getExpression().getOperands(1); + boolean bothValues = l.getNodeCase() == Operand.NodeCase.VALUE + && r.getNodeCase() == Operand.NodeCase.VALUE; + if (bothValues && other.getNodeCase() == Operand.NodeCase.VARIABLE) { + return true; // fold path + } + boolean oneFieldOneValue = + (l.getNodeCase() == Operand.NodeCase.VARIABLE + && r.getNodeCase() == Operand.NodeCase.VALUE) + || (l.getNodeCase() == Operand.NodeCase.VALUE + && r.getNodeCase() == Operand.NodeCase.VARIABLE); + return ("eq".equals(op) || "ne".equals(op)) + && oneFieldOneValue + && other.getNodeCase() == Operand.NodeCase.VALUE; // solve path + } + + /** + * Resolve a comparison operand to a numeric SQL expression in double space: + * variable → column cast to double; value → double literal; nested + * add/sub/mult/div → recursive {@code cb.sum}/{@code diff}/{@code prod}/{@code quot}. + */ + private jakarta.persistence.criteria.Expression resolveNumericExpression( + Operand operand, Scope scope) { + switch (operand.getNodeCase()) { + case VARIABLE -> { + return scope.resolvePath(operand.getVariable()).as(Double.class); + } + case VALUE -> { + Object v = PlanValues.protoValueToJava(operand.getValue()); + if (!(v instanceof Number n)) { + throw new IllegalArgumentException( + "Arithmetic comparison requires numeric operands, got " + + typeName(v)); + } + return cb.literal(n.doubleValue()); + } + case EXPRESSION -> { + PlanResourcesFilter.Expression expr = operand.getExpression(); + String op = expr.getOperator(); + if ("mod".equals(op)) { + throw new IllegalArgumentException( + "mod is not supported in comparisons: CEL % is integer-only and " + + "attribute values are always doubles at check time, so " + + "the condition can never be satisfied by the PDP"); + } + if (!ARITHMETIC_OPS.contains(op)) { + throw new IllegalArgumentException( + "Unexpected " + op + "() expression inside an arithmetic " + + "comparison operand"); + } + if (expr.getOperandsCount() != 2) { + throw new IllegalArgumentException(op + " requires exactly 2 operands"); + } + jakarta.persistence.criteria.Expression l = + resolveNumericExpression(expr.getOperands(0), scope); + jakarta.persistence.criteria.Expression r = + resolveNumericExpression(expr.getOperands(1), scope); + return switch (op) { + case "add" -> cb.sum(l, r); + case "sub" -> cb.diff(l, r); + case "mult" -> cb.prod(l, r); + case "div" -> cb.quot(l, r).as(Double.class); + default -> throw new IllegalArgumentException( + "Unsupported arithmetic operator: " + op); + }; + } + default -> throw new IllegalArgumentException( + "Unexpected operand type in arithmetic comparison: " + + operand.getNodeCase()); + } + } + // -- add (fold + solve for string concat / numeric translation) -- /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java index 045d7d2f..4a17b648 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java @@ -100,7 +100,14 @@ private record Seed(String id, boolean aBool, String aString, int aNumber, // Field-to-field witness: aString == aOptionalString == a tag name, so the // field-to-field and lambda-field-to-field oracles are non-degenerate. new Seed("a9", true, "same", 4, "same", - List.of(new Tag("t9a", "same")), List.of()) + List.of(new Tag("t9a", "same")), List.of()), + // Field-to-field LIKE witnesses: the NEEDLE column (aOptionalString) holds LIKE + // metacharacters. b1 discriminates escaping for all three ops ("oneXtwo" does not + // literally contain "one_two", but an unescaped '_' wildcard would match the 'X'); + // b2/b3 are literal % and \ matches that only work when the escape is correct. + new Seed("b1", true, "oneXtwo", 7, "one_two", List.of(), List.of()), + new Seed("b2", false, "50%_off", 6, "%_o", List.of(), List.of()), + new Seed("b3", true, "back\\slash", -4, "k\\s", List.of(), List.of()) ); private static GenericContainer cerbos; @@ -251,6 +258,9 @@ private static List adapterFilteredIds(String action) { "size-threshold", "size-filter-count", "string-size", "ternary-cmp", "ternary-expr-cond", "ternary-nested", "ternary-negated", "ternary-bare", "ternary-value-first", "ternary-null-cond", + "f2f-contains", "f2f-startswith", "f2f-endswith", + "arith-add", "arith-vf", "arith-sub", "arith-mult-neg", + "arith-div", "arith-div-frac", "arith-both", }) void adapterMatchesCheckOracle(String action) { List oracle = oracleAllowedIds(action); diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 4634aa81..2a87a86d 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -984,7 +984,8 @@ void notStartsWith() { // -- CEL primitives (PR #223) -- // `empty-collection` (size(coll) == 0) is natively supported via the existing emptiness // path in trySizeComparison, and the CEL ternary (`if(cond, then, else)`) is rewritten into - // an OR of guarded branch predicates. Arithmetic, regex, casts, and list indexing still + // an OR of guarded branch predicates. Arithmetic (add/sub/mult/div) in comparisons is + // translated as double-space SQL arithmetic. mod, regex, casts, and list indexing still // throw — the Spring Data adapter has no shape for them in its Criteria-based predicate // builder. @@ -998,28 +999,35 @@ void emptyCollection() { } @Test - void arithAddThrows() { - // The planner emits gt(add(field, 1.0), 2.0); handleAddComparison rejects non-eq/ne. - assertActionThrows("arith-add", FIELD_MAP, "add", "gt"); + void arithAdd() { + // aNumber + 1.0 > 2.0 → aNumber > 1 (double-space SQL arithmetic). + // r1: 1+1=2 > 2 ✗ r2: 3 > 2 ✓ r3: 4 > 2 ✓ + assertEquals(List.of("2", "3"), run("arith-add")); } @Test - void arithSubThrows() { - assertActionThrows("arith-sub", FIELD_MAP, "sub"); + void arithSub() { + // aNumber - 1.0 < 2.0 → aNumber < 3 → r1 (1), r2 (2). + assertEquals(List.of("1", "2"), run("arith-sub")); } @Test - void arithMultThrows() { - assertActionThrows("arith-mult", FIELD_MAP, "mult"); + void arithMult() { + // aNumber * 2.0 > 2.0 → aNumber > 1 → r2, r3. + assertEquals(List.of("2", "3"), run("arith-mult")); } @Test - void arithDivThrows() { - assertActionThrows("arith-div", FIELD_MAP, "div"); + void arithDiv() { + // aNumber / 2.0 > 0.0 → aNumber > 0 → all rows. CEL division on attributes is + // double division, so the adapter divides in double space (no truncation). + assertEquals(List.of("1", "2", "3"), run("arith-div")); } @Test void arithModThrows() { + // int(aNumber) % 2 == 0 — CEL % is integer-only while attribute values are + // doubles, so mod comparisons stay unsupported (see tryArithmeticComparison). assertActionThrows("arith-mod", FIELD_MAP, "mod"); } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index a90acc8d..362cdc37 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -680,42 +680,8 @@ void emptyCollectionBuildsNotExists() { nval(0)))); } - @Test - void arithAddInComparisonThrows() { - // gt(add(field, 1.0), 2.0) — handleAddComparison rejects non-eq/ne ops. - assertConditionThrows( - exprOp("gt", - exprOp("add", var("request.resource.attr.aNumber"), nval(1)), - nval(2)), - "add", "gt"); - } - - @Test - void arithSubThrows() { - assertConditionThrows( - exprOp("lt", - exprOp("sub", var("request.resource.attr.aNumber"), nval(1)), - nval(2)), - "sub"); - } - - @Test - void arithMultThrows() { - assertConditionThrows( - exprOp("gt", - exprOp("mult", var("request.resource.attr.aNumber"), nval(2)), - nval(2)), - "mult"); - } - - @Test - void arithDivThrows() { - assertConditionThrows( - exprOp("gt", - exprOp("div", var("request.resource.attr.aNumber"), nval(2)), - nval(0)), - "div"); - } + // add/sub/mult/div appearing as a comparison operand are supported (double-space SQL + // arithmetic) — see ArithmeticComparisons. Only mod remains rejected. @Test void arithModThrows() { @@ -855,13 +821,14 @@ void fieldToFieldOrderingKeepsOperandDirection() { } @Test - void fieldToFieldNonComparisonOperatorStillThrows() { - // contains(var, var) has no portable JPA translation — the specific message stays. + void fieldToFieldUnsupportedOperatorStillThrows() { + // contains/startsWith/endsWith(var, var) are supported (see FieldToFieldStringMatch); + // anything else without a column-to-column translation keeps the specific message. assertConditionThrows( - exprOp("contains", + exprOp("matches", var("request.resource.attr.aString"), var("request.resource.attr.createdBy")), - "Field-to-field", "contains"); + "Field-to-field", "matches"); } @Test @@ -1564,4 +1531,229 @@ void operatorOverrideIsUsed() { "eq", (cb, field, value) -> cb.isNull(field)); assertEquals(0, runCount(cond, overrides)); } + + // -- SPIKE 1: field-to-field contains/startsWith/endsWith -- + // The needle is a COLUMN, so LIKE metacharacters it holds must be escaped dynamically + // (nested REPLACE) before being wrapped in wildcards. CEL semantics: case-sensitive + // literal substring; a NULL needle is a missing attribute → deny (row excluded). + + @Nested + class FieldToFieldStringMatch { + + private ResourceEntity row(String id, String aString, String createdBy) { + ResourceEntity r = new ResourceEntity(id); + r.setaString(aString); + r.setCreatedBy(createdBy); + return r; + } + + private int count(String op) { + return runCount(exprOp(op, + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy"))); + } + + @Test + void metacharactersInNeedleColumnAreEscaped() { + // "oneXtwo" does NOT literally contain/start-with/end-with "one_two", but an + // UNESCAPED pattern ('%one_two%' / 'one_two%' / '%one_two') would match all + // three ways because '_' matches the 'X'. All three must be no-match. + withResource(row("f2f-like-1", "oneXtwo", "one_two"), () -> { + assertEquals(0, count("contains")); + assertEquals(0, count("startsWith")); + assertEquals(0, count("endsWith")); + }); + } + + @Test + void containsColumnLiteralMatch() { + withResource(row("f2f-like-2", "a_one_two_b", "one_two"), () -> + assertEquals(1, count("contains"))); + } + + @Test + void startsWithColumn() { + withResource(row("f2f-like-3", "one_twoTail", "one_two"), () -> { + assertEquals(1, count("startsWith")); + assertEquals(1, count("contains")); + assertEquals(0, count("endsWith")); + }); + } + + @Test + void endsWithColumn() { + withResource(row("f2f-like-4", "Headone_two", "one_two"), () -> { + assertEquals(1, count("endsWith")); + assertEquals(0, count("startsWith")); + }); + } + + @Test + void percentAndBackslashInNeedleColumn() { + withResource(row("f2f-like-5", "50%_off", "%_o"), () -> + assertEquals(1, count("contains"))); + withResource(row("f2f-like-6", "back\\slash", "k\\s"), () -> + assertEquals(1, count("contains"))); + // A literal backslash in the needle must not act as an escape prefix. + withResource(row("f2f-like-7", "backXslash", "k\\s"), () -> + assertEquals(0, count("contains"))); + } + + @Test + void nullNeedleColumnExcludesRow() { + // CEL: missing attribute → error → deny. Guarded explicitly because some + // dialects' CONCAT treats NULL as '' which would turn the pattern into + // match-anything '%%'. + withResource(row("f2f-like-8", "anything", null), () -> { + assertEquals(0, count("contains")); + assertEquals(0, count("startsWith")); + assertEquals(0, count("endsWith")); + }); + } + + @Test + void emptyNeedleColumnMatchesLikeCel() { + // CEL: "x".contains("") / startsWith("") / endsWith("") are all true. + withResource(row("f2f-like-9", "x", ""), () -> { + assertEquals(1, count("contains")); + assertEquals(1, count("startsWith")); + assertEquals(1, count("endsWith")); + }); + } + } + + // -- SPIKE 2: arithmetic (add/sub/mult/div) as a comparison operand -- + // Cerbos attribute values are ALWAYS CEL doubles (protobuf Value numbers), so the only + // arithmetic that can evaluate at check time is double-typed — verified against a live + // PDP: `R.attr.n + 1 > 2` (int literal) is a no-overload error → deny, `+ 1.0` works, + // and `/ 2.0` is true double division (5/2.0 == 2.5). The adapter therefore computes + // the whole comparison in double space; integer truncation is never observable. + // `mod` stays unsupported: CEL `%` is int-only, so it always errors on attributes. + + @Nested + class ArithmeticComparisons { + + private ResourceEntity seeded() { + ResourceEntity r = new ResourceEntity("arith-seed-1"); + r.setaNumber(5); + return r; + } + + private Operand numVar() { + return var("request.resource.attr.aNumber"); + } + + @Test + void addInGtComparison() { + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("gt", + exprOp("add", numVar(), nval(1)), nval(2)))); + assertEquals(0, runCount(exprOp("gt", + exprOp("add", numVar(), nval(1)), nval(6)))); + }); + } + + @Test + void subInLtComparison() { + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("lt", + exprOp("sub", numVar(), nval(1)), nval(10)))); + assertEquals(0, runCount(exprOp("lt", + exprOp("sub", numVar(), nval(1)), nval(2)))); + // Constant-minus-field keeps direction: 10 - 5 = 5 <= 5. + assertEquals(1, runCount(exprOp("le", + exprOp("sub", nval(10), numVar()), nval(5)))); + }); + } + + @Test + void multInComparisonIncludingNegativeConstant() { + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("gt", + exprOp("mult", numVar(), nval(2)), nval(9)))); + assertEquals(0, runCount(exprOp("gt", + exprOp("mult", numVar(), nval(2)), nval(10)))); + // Negative multiplier: arithmetic is emitted on the SQL side, so no + // inequality flipping is needed: 5 * -2 = -10 < 3. + assertEquals(1, runCount(exprOp("lt", + exprOp("mult", numVar(), nval(-2)), nval(3)))); + assertEquals(1, runCount(exprOp("gt", + exprOp("mult", numVar(), nval(-2)), nval(-11)))); + }); + } + + @Test + void divIsDoubleDivision() { + // CEL semantics on attributes are double: 5 / 2.0 == 2.5, NOT 2 (int + // truncation is a CEL runtime error on double attrs, verified vs live PDP). + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("eq", + exprOp("div", numVar(), nval(2)), nval(2.5)))); + assertEquals(0, runCount(exprOp("eq", + exprOp("div", numVar(), nval(2)), nval(2)))); + assertEquals(1, runCount(exprOp("ge", + exprOp("div", numVar(), nval(2)), nval(2.5)))); + assertEquals(0, runCount(exprOp("ge", + exprOp("div", numVar(), nval(2)), nval(2.6)))); + }); + } + + @Test + void valueFirstComparisonIsMirrored() { + // 2 < aNumber + 1 → NormalizedBinary mirrors to (aNumber + 1) > 2. + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("lt", + nval(2), exprOp("add", numVar(), nval(1))))); + assertEquals(0, runCount(exprOp("lt", + nval(6), exprOp("add", numVar(), nval(1))))); + }); + } + + @Test + void arithmeticOnBothSides() { + // aNumber + 1 aNumber * 2 → 6 vs 10. + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("lt", + exprOp("add", numVar(), nval(1)), + exprOp("mult", numVar(), nval(2))))); + assertEquals(0, runCount(exprOp("gt", + exprOp("add", numVar(), nval(1)), + exprOp("mult", numVar(), nval(2))))); + }); + } + + @Test + void nestedArithmetic() { + // (aNumber + 1) * 2 > 11 → 12 > 11. + withResource(seeded(), () -> { + assertEquals(1, runCount(exprOp("gt", + exprOp("mult", exprOp("add", numVar(), nval(1)), nval(2)), + nval(11)))); + assertEquals(0, runCount(exprOp("gt", + exprOp("mult", exprOp("add", numVar(), nval(1)), nval(2)), + nval(12)))); + }); + } + + @Test + void modStillThrows() { + // CEL `%` has no double overload and attribute values are always doubles, so a + // mod comparison can never be satisfied at check time — translating it to SQL + // MOD would fabricate rows the PDP denies. It must keep throwing. + assertConditionThrows( + exprOp("eq", + exprOp("mod", numVar(), nval(2)), nval(1)), + "mod"); + } + + @Test + void nonNumericOperandThrows() { + // lt over string concatenation has no numeric translation. + assertConditionThrows( + exprOp("lt", + exprOp("add", var("request.resource.attr.aString"), sval("x")), + sval("z")), + "numeric"); + } + } } diff --git a/spring-data/src/test/resources/adversarial-policy.yaml b/spring-data/src/test/resources/adversarial-policy.yaml index 39a9ec7a..e5c30c0e 100644 --- a/spring-data/src/test/resources/adversarial-policy.yaml +++ b/spring-data/src/test/resources/adversarial-policy.yaml @@ -283,3 +283,87 @@ resourcePolicy: condition: match: expr: '(R.attr.aOptionalString != "x" ? R.attr.aNumber : 0) > 1' + + # -- field-to-field contains/startsWith/endsWith: the needle is a COLUMN, so its LIKE + # metacharacters (b1/b2/b3 seeds hold _ % \) must be escaped dynamically; NULL needle + # rows (a2/a4/a8) are a CEL missing-attr error (deny) and must be excluded in SQL too. + - actions: ["f2f-contains"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString.contains(R.attr.aOptionalString) + + - actions: ["f2f-startswith"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString.startsWith(R.attr.aOptionalString) + + - actions: ["f2f-endswith"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: R.attr.aString.endsWith(R.attr.aOptionalString) + + # -- arithmetic in comparisons. Literals are double-typed on purpose: Cerbos attribute + # values are CEL doubles, so int-literal arithmetic (e.g. R.attr.aNumber + 1) is a CEL + # no-overload error that denies EVERY row at check time — and the wire plan is identical + # for both spellings, so the adapter translates the (only satisfiable) double semantics. + - actions: ["arith-add"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aNumber + 1.0 > 2.0' + + # Value-first: constant on the left, arithmetic on the right (mirrored by the adapter). + - actions: ["arith-vf"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '2.0 < R.attr.aNumber + 1.0' + + - actions: ["arith-sub"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aNumber - 3.0 <= 0.0' + + # Negative multiplier: emitted as SQL-side arithmetic, so no inequality flipping. + # Negative seeds (a2:-2, a5:-5, b3:-4) sit on the excluded side. + - actions: ["arith-mult-neg"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aNumber * -2.0 < 3.0' + + # Division truncation probe: 2/2.0 == 1.0 matches, but 3/2.0 == 1.5 must NOT — an + # adapter doing integer division would wrongly include the aNumber==3 seed (a6). + - actions: ["arith-div"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aNumber / 2.0 == 1.0' + + # Fractional threshold over division (double-space comparison end to end). + - actions: ["arith-div-frac"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aNumber / 2.0 >= 1.5' + + # Arithmetic on BOTH sides of the comparison: n + 1 > n * 2 ⇔ n < 1. + - actions: ["arith-both"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aNumber + 1.0 > R.attr.aNumber * 2.0' From ce647facd49d2b6fbf6904c8b93687d2704fc8a7 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 20 Jul 2026 18:34:04 +0100 Subject: [PATCH 16/20] =?UTF-8?q?fix(spring-data):=20close=203VL=20negatio?= =?UTF-8?q?n=20leaks,=20IEEE-double=20fidelity,=20join=20anchoring=20?= =?UTF-8?q?=E2=80=94=20clean-room=20audit=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-lens audit (planner-source shape inventory, adversarial code review, oracle fuzzing) found 10 correctness bugs, 3 of them authorization leaks. All fixed test-first with the leak reproduced against the check-API oracle: - ternary rewrite gains an arm that is UNKNOWN when the condition column is NULL, so !(ternary) can no longer include null-condition rows - collection macros are tri-state with CEL error-absorption semantics (exists/all/exists_one/filter/size(filter)/hasIntersection(map)) via a COUNT-comparison unknown-element detector - constant-receiver contains/startsWith/endsWith no longer inverted; NormalizedBinary only reorders order-normalizable operators - ne over unsolvable concat -> IS NOT NULL (was always-TRUE) - arithmetic folds constants in Java (exact IEEE incl Inf/NaN), casts columns via cb.toDouble (real SQL cast), binds mixed constants as doubles - fractional size() thresholds get integral-count semantics (was longValue truncation); division guarded with NULLIF (zero divisor -> excluded, documented vs CEL Infinity) - multi-hop relation chains join through every hop (Scope.resolveRelation ownership threading); relation subqueries in lambdas correlate the owning From; leaf operand-count guard; arithmetic path consults overrides Also: 21 oracle fuzz probes kept as regressions (composition coverage: ternary/arith/f2f inside lambdas, struct access, deep nesting), pinned throws for timestamp()/matches()/indexing, README gotchas for the NULL semantics and division guard. 344 tests green (76 oracle shapes). Upstream findings to file separately: planner folds has() to ALWAYS_ALLOWED for unknown attrs; isSet never appears on the wire. Signed-off-by: Alex Olivier --- spring-data/README.md | 27 +- .../springdata/OperatorFunction.java | 12 +- .../cerbos/queryplan/springdata/Scope.java | 83 ++ .../SpringDataQueryPlanAdapter.java | 721 +++++++++++++---- .../AdversarialConformanceTest.java | 109 ++- .../springdata/SpringDataIntegrationTest.java | 10 +- .../SpringDataQueryPlanAdapterTest.java | 726 +++++++++++++++++- .../test/resources/adversarial-policy.yaml | 324 ++++++++ 8 files changed, 1851 insertions(+), 161 deletions(-) diff --git a/spring-data/README.md b/spring-data/README.md index 56320ba2..4d7687b0 100644 --- a/spring-data/README.md +++ b/spring-data/README.md @@ -108,7 +108,7 @@ Map each `request.resource.attr.` to a JPA path or a relation: | `eq` / `ne` | `cb.equal` / `cb.notEqual` (auto `isNull`/`isNotNull` for `null` RHS) | | `lt` / `gt` / `le` / `ge` | `cb.lessThan` / `greaterThan` / `lessThanOrEqualTo` / `greaterThanOrEqualTo` | | `in` | `path.in(values)` or correlated `EXISTS` for collections | -| `contains` / `startsWith` / `endsWith` | `cb.like(...)` with proper `_`/`%`/`\` escaping | +| `contains` / `startsWith` / `endsWith` | `cb.like(...)` with proper `_`/`%`/`\` escaping — incl. the constant-receiver form (`"a,b".contains(R.attr.x)`: the constant is the haystack, the column the needle) | | `isSet(field, true/false)` | `cb.isNotNull` / `cb.isNull` | | `hasIntersection(coll, [values])` | Correlated `EXISTS` with `IN` | | `hasIntersection(coll.map(x, x.f), [values])` | Correlated `EXISTS` with projected `IN` | @@ -121,6 +121,7 @@ Map each `request.resource.attr.` to a JPA path or a relation: | Ternary (`cond ? a : b`) | Predicate rewrite: `(cond AND cmp(a, v)) OR (NOT cond AND cmp(b, v))` — nested, boolean-position, and value-first forms compose; constant residues fold (see Gotchas for `NULL` semantics) | | Arithmetic in comparisons (`add`/`sub`/`mult`/`div`) | `cb.sum`/`diff`/`prod`/`quot` in double space, compared via `eq`/`ne`/`lt`/`gt`/`le`/`ge`; nested and both-sides shapes compose (see Gotchas — CEL attribute arithmetic is double arithmetic) | | Field-to-field `contains`/`startsWith`/`endsWith` | `cb.like` over a `REPLACE`-escaped column-derived pattern (`\`, `%`, `_`), with an `IS NOT NULL` needle guard | +| Multi-hop relation chains (`R.attr.categories.subCategories`) | Correlated subquery joining through every hop; `exists`/`in`/`hasIntersection`/`size` all treat the chain as the flattened union of tail elements | | `exists(coll, lambda)` | Correlated `EXISTS` with lambda body | | `exists_one(coll, lambda)` | Correlated `(SELECT COUNT...) = 1` | | `all(coll, lambda)` | `NOT EXISTS (... AND NOT(body))` | @@ -196,6 +197,30 @@ needle column excludes the row (`IS NOT NULL` guard), which matches CEL missing-attribute → deny and also defends against dialects whose `CONCAT` treats `NULL` as `''` (which would otherwise turn the pattern into match-everything `'%%'`). +### NULL columns follow CEL error semantics — even under negation + +Cerbos denies a check when the condition hits a CEL evaluation error (typically a +null/missing attribute where no null overload exists). The adapter mirrors this with SQL +three-valued logic, including the places where naive translations leak under `NOT`: + +- Collection macros are tri-state: `R.attr.items.all(t, t.qty > 0)` with an item whose + `qty` is NULL yields UNKNOWN (row excluded under both `all(...)` and `!all(...)`), + matching CEL's error-absorption rules — `exists` is still true if *any* element + matches, `all` is still false if *any* element fails, `exists_one` errors on any + unknown element. This costs two extra correlated `COUNT` subqueries per macro. +- Ternaries carry a third arm that is UNKNOWN exactly when the condition column is NULL, + so `!(ternary...)` cannot flip a null-condition row to included. +- `ne` against an unsolvable string concatenation reduces to `IS NOT NULL`, not `TRUE`. + +### Division by a column is guarded with `NULLIF` — zero divisors deny + +CEL double division by zero yields ±Infinity (a defined result that a comparison could +turn into ALLOW); SQL raises an error that would abort the whole query. The adapter +divides by `NULLIF(divisor, 0)`, so zero-divisor rows become UNKNOWN and are excluded. +This is deliberately under-inclusive: a policy relying on `x / 0 == Infinity` semantics +will deny those rows here while a per-resource `check` would allow them. Constant +arithmetic (including `0/0 → NaN`) is folded in Java with full IEEE fidelity. + ### Ternary with a `NULL` condition column excludes the row A comparison wrapping a ternary is rewritten as diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java index 94491eb7..375a8c6c 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java @@ -20,12 +20,20 @@ * {@code 5 < R.attr.x} is mirrored to field-first form, so the override is looked up (and invoked) * under {@code gt}, matching the semantics of the predicate being built. * + *

Arithmetic comparisons ({@code R.attr.n + 1.0 > 2.0}) also consult the override when the + * other side of the comparison is a plan constant: the {@code field} argument is the composed + * arithmetic SQL expression (not a bare path) and {@code value} is the constant — always a + * {@link Double}, because the arithmetic path evaluates in IEEE double space end to end. + * *

Overrides are not consulted for operators that translate to correlated {@code EXISTS} * subqueries against a {@code Relation} mapping — {@code exists}/{@code exists_one}/{@code all}/ * {@code except}/{@code filter}, {@code hasIntersection} over a relation, {@code size(...)}, and the * relation form of {@code in} — because those have no single resolved (field, value) pair. The same - * applies to {@code size(string)} length comparisons and field-to-field comparisons - * ({@code R.attr.a == R.attr.b}), where the right-hand side is a column, not a value. + * applies to {@code size(string)} length comparisons, field-to-field comparisons + * ({@code R.attr.a == R.attr.b}) and arithmetic-vs-expression comparisons, where the right-hand + * side is a column or composed expression, not a value, and to constant-receiver string matches + * ({@code "a,b".contains(R.attr.x)}), where the COLUMN is the needle and the constant the + * haystack — invoking a {@code contains} override there would silently invert the semantics. */ @FunctionalInterface public interface OperatorFunction { diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java index b780c590..d88712bc 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java @@ -21,10 +21,39 @@ sealed interface Scope permits Scope.RootScope, Scope.LambdaScope { AttributeMapping resolveMapping(String cerbosVar); + /** + * Resolve a relation-valued variable to the JOIN CHAIN reaching its elements together with + * the scope that OWNS the first hop, or {@code null} when the variable is not + * relation-valued. The owner is the resolution site — the root scope for + * {@code request.resource.attr.*} references (even when resolved from inside a lambda, + * whose scope merely delegates outward), or the lambda scope itself when the chain hangs + * off the lambda element. A subquery over the relation must correlate the OWNER's + * {@code from()}: joining the chain off any other {@code From} either fails at query-build + * time or silently queries a same-named collection on the wrong entity. + */ + ResolvedRelation resolveRelation(String cerbosVar); + From from(); AbstractQuery parentQuery(); + /** + * A relation-valued variable resolved to the Relations to join through — in hop order, + * first hop owned by {@code owner.from()} — ending at the {@code tail} Relation whose + * elements the enclosing operator ranges over. Multi-hop chains + * ({@code categories.subCategories}) denote the FLATTENED union of tail elements across + * the intermediate hops, which is exactly what a correlated join chain expresses. + */ + record ResolvedRelation(Scope owner, List chain) { + public ResolvedRelation { + chain = List.copyOf(chain); + } + + AttributeMapping.Relation tail() { + return chain.get(chain.size() - 1); + } + } + static Scope root(From root, AbstractQuery query, Map mapper) { return new RootScope(root, query, mapper); } @@ -46,6 +75,28 @@ static Scope rebase(Scope scope, From correlated, AbstractQuery sub) { return new LambdaScope(correlated, sub, ls.relation(), ls.lambdaVar(), ls.outer()); } + /** + * Re-root the scope CHAIN for use inside a subquery that correlated {@code target}'s + * {@code from()}: the level identical to {@code target} is rebased at {@code correlated} + * (see {@link #rebase}); levels between {@code scope} and the target keep their Froms — + * paths through them stay legal as implicit correlation references, the same reliance + * {@link #rebase} already has on untouched {@code outer} links — but adopt {@code sub} as + * the query any deeper subqueries are built against. When {@code scope == target} this is + * exactly {@link #rebase}. Identity comparison is deliberate: the target is always a scope + * object returned by {@link #resolveRelation} on this same chain. + */ + static Scope rebaseAt(Scope scope, Scope target, From correlated, AbstractQuery sub) { + if (scope == target) { + return rebase(scope, correlated, sub); + } + if (scope instanceof LambdaScope ls && ls.outer() != null) { + return new LambdaScope(ls.from(), sub, ls.relation(), ls.lambdaVar(), + rebaseAt(ls.outer(), target, correlated, sub)); + } + throw new IllegalArgumentException( + "Relation owner scope is not on the current resolution chain"); + } + record RootScope(From from, AbstractQuery parentQuery, Map mapper) implements Scope { @Override @@ -78,6 +129,15 @@ public AttributeMapping resolveMapping(String cerbosVar) { } throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); } + + @Override + public ResolvedRelation resolveRelation(String cerbosVar) { + RelationChain chain = resolveRelationChain(mapper, cerbosVar); + if (chain != null && chain.tail() == null) { + return new ResolvedRelation(this, chain.relations()); + } + return null; + } } /** @@ -126,6 +186,29 @@ public AttributeMapping resolveMapping(String cerbosVar) { } return AttributeMapping.field(suffix); } + + @Override + public ResolvedRelation resolveRelation(String cerbosVar) { + if (!isLambdaRef(cerbosVar)) { + // An outer reference: the OWNING scope is found further out — e.g. + // request.resource.attr.tags inside a categories lambda is owned by the root. + return outer != null ? outer.resolveRelation(cerbosVar) : null; + } + String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); + if (suffix.isEmpty()) { + return null; // the bare lambda var is the element itself, not a relation + } + List chain = new ArrayList<>(); + AttributeMapping.Relation current = relation; + for (String part : suffix.split("\\.")) { + if (!(current.fields().get(part) instanceof AttributeMapping.Relation next)) { + return null; // scalar (or unmapped) hop — not relation-valued + } + chain.add(next); + current = next; + } + return new ResolvedRelation(this, chain); + } } /** diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index bee54b2e..709c85c5 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -17,6 +17,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; /** * Translates a Cerbos {@code PlanResources} response into a Spring Data JPA @@ -166,11 +167,28 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, * when swapping — lets every downstream handler assume field-first order. A consequence * is that {@link OperatorFunction} overrides are consulted under the mirrored operator: * a value-first {@code lt} is looked up as {@code gt}. + * + *

Only operators whose semantics survive a swap are reordered: symmetric ones + * ({@code eq}/{@code ne}/{@code in}/{@code hasIntersection}) and the mirrorable + * inequalities ({@code lt}/{@code gt}/{@code le}/{@code ge}). The CEL string-match + * methods ({@code contains}/{@code startsWith}/{@code endsWith}) are RECEIVER-SENSITIVE: + * {@code "a,b".contains(R.attr.x)} arrives as {@code contains(value, variable)} where + * the constant is the haystack — swapping it would silently invert haystack and needle + * (translating {@code x LIKE '%a,b%'} instead of testing whether {@code "a,b"} contains + * the column value). Those keep planner source order and are handled positionally by + * {@link #handleLeafOperator}. */ private record NormalizedBinary(String op, List operands) { + /** Operators whose operands may be reordered without changing meaning. */ + private static final Set ORDER_NORMALIZABLE = Set.of( + "eq", "ne", "lt", "gt", "le", "ge", + "in", "hasIntersection", "has_intersection"); + static NormalizedBinary of(String op, List operands) { - if (operands.size() == 2 && rank(operands.get(0)) < rank(operands.get(1))) { + if (ORDER_NORMALIZABLE.contains(op) + && operands.size() == 2 + && rank(operands.get(0)) < rank(operands.get(1))) { return new NormalizedBinary(mirror(op), List.of(operands.get(1), operands.get(0))); } return new NormalizedBinary(op, operands); @@ -206,7 +224,7 @@ private static String mirror(String op) { * Rewrite a comparison wrapping a CEL ternary — {@code cmp(if(c, a, b), other)} — into a * pure predicate: * - *

{@code (pred(c) AND cmp(a, other)) OR (NOT pred(c) AND cmp(b, other))}
+ *
{@code (pred(c) AND cmp(a, other)) OR (NOT pred(c) AND cmp(b, other)) OR NOT(pred(c) OR NOT pred(c))}
* * We rewrite instead of emitting {@code CASE WHEN} ({@code cb.selectCase}) because this * translator is predicate-only: every existing typed leaf path — field-first @@ -216,10 +234,14 @@ private static String mirror(String op) { * paths, so a ternary branch behaves identically to the same comparison written directly. * Recursion also handles nested ternaries and a ternary on the other side for free. * - *

Null semantics: under SQL three-valued logic a NULL condition column makes both - * {@code pred(c)} and {@code NOT pred(c)} unknown, so the row is excluded from both - * branches. This matches Cerbos: a null/missing condition in a CEL ternary is an - * evaluation error and the check denies. + *

Null semantics: a null/missing condition in a CEL ternary is an evaluation error + * and the check denies, so the SQL must evaluate to UNKNOWN — never FALSE — when the + * condition column is NULL. The two branch arms alone are not enough: with both branch + * comparisons false they evaluate {@code (NULL AND FALSE) OR (NULL AND FALSE) = FALSE}, + * which {@code not(...)} flips to TRUE and leaks rows the PDP denies. The third arm + * ({@link #unknownWhenConditionUnknown}) restores the missing UNKNOWN: it is FALSE for a + * known condition (no effect on the OR) and UNKNOWN for a NULL one, driving the whole OR + * to UNKNOWN so the row is excluded under BOTH polarities. * * @return the rewritten predicate, or {@code null} if this comparison involves no ternary */ @@ -270,7 +292,22 @@ private Predicate tryTernaryComparison(String op, List operands, Scope // unsafe. return cb.or( cb.and(traverse(condition, scope), thenCmp), - cb.and(negate(traverse(condition, scope)), elseCmp)); + cb.and(negate(traverse(condition, scope)), elseCmp), + unknownWhenConditionUnknown(condition, scope)); + } + + /** + * UNKNOWN exactly when {@code condition} is UNKNOWN, FALSE when it is known: + * {@code NOT(c OR NOT c)}. Truth table: condition TRUE → {@code NOT(TRUE OR FALSE)} = + * FALSE; condition FALSE → {@code NOT(FALSE OR TRUE)} = FALSE; condition UNKNOWN → + * {@code NOT(UNKNOWN OR UNKNOWN)} = UNKNOWN. As the last arm of the ternary OR it + * therefore vanishes for known conditions and forces the whole predicate to UNKNOWN for + * NULL-derived ones — matching the CEL evaluation error (deny) under both polarities. + * The condition is translated fresh for each occurrence (Hibernate 6 negation is + * stateful — see {@link #negate}). + */ + private Predicate unknownWhenConditionUnknown(Operand condition, Scope scope) { + return negate(cb.or(traverse(condition, scope), negate(traverse(condition, scope)))); } /** @@ -278,7 +315,7 @@ private Predicate tryTernaryComparison(String op, List operands, Scope * so both branches are themselves boolean. Same predicate rewrite (and same rationale and * null semantics) as {@link #tryTernaryComparison}: * - *

{@code (pred(c) AND pred(a)) OR (NOT pred(c) AND pred(b))}
+ *
{@code (pred(c) AND pred(a)) OR (NOT pred(c) AND pred(b)) OR NOT(pred(c) OR NOT pred(c))}
*/ private Predicate handleBareTernary(List operands, Scope scope) { if (operands.size() != 3) { @@ -304,7 +341,8 @@ private Predicate handleBareTernary(List operands, Scope scope) { // Translate the condition once per occurrence — see tryTernaryComparison. return cb.or( cb.and(traverse(condition, scope), booleanBranchPredicate(thenBranch, scope)), - cb.and(negate(traverse(condition, scope)), booleanBranchPredicate(elseBranch, scope))); + cb.and(negate(traverse(condition, scope)), booleanBranchPredicate(elseBranch, scope)), + unknownWhenConditionUnknown(condition, scope)); } /** @@ -342,13 +380,24 @@ private static Boolean constantBooleanOrNull(Operand o) { // -- Leaf operators (eq/ne/lt/gt/le/ge/contains/startsWith/endsWith) -- + /** The receiver-sensitive CEL string-match methods (see {@link NormalizedBinary}). */ + private static final Set STRING_MATCH_OPS = + Set.of("contains", "startsWith", "endsWith"); + /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ private Predicate handleLeafOperator(String op, List operands, Scope scope) { + // Every leaf operator is binary. Extra operands are a malformed plan and must fail + // loudly: the collector below would otherwise silently DROP one (a 3-operand eq + // kept the field-to-field comparison and ignored the value). + if (operands.size() != 2) { + throw new IllegalArgumentException( + op + " requires exactly 2 operands, got " + operands.size()); + } + // Constant-vs-constant comparisons are statically evaluated. The planner never emits // them directly, but ternary substitution produces them — the else branch of // `(aBool ? aNumber : 0) > 0` becomes gt(value(0), value(0)). if (TERNARY_COMPARISONS.contains(op) - && operands.size() == 2 && operands.get(0).getNodeCase() == Operand.NodeCase.VALUE && operands.get(1).getNodeCase() == Operand.NodeCase.VALUE) { return constantComparison(op, @@ -356,6 +405,33 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc PlanValues.protoValueToJava(operands.get(1).getValue())); } + // Constant-receiver string matches: `"a,b".contains(R.attr.x)` arrives as + // contains(value, variable) — the CONSTANT is the haystack and the COLUMN the + // needle (NormalizedBinary deliberately leaves these in source order). This must + // be checked BEFORE the add-detection below so an unfolded concat receiver + // (`("a" + "b").contains(R.attr.x)`) routes here too, not into the add-solve path + // (which would fold the constant and translate the INVERTED column-haystack LIKE). + if (STRING_MATCH_OPS.contains(op) + && operands.get(1).getNodeCase() == Operand.NodeCase.VARIABLE) { + Object receiver = constantReceiverOrNull(operands.get(0)); + if (receiver != null) { + if (!(receiver instanceof String haystack)) { + throw new IllegalArgumentException( + op + " requires a string receiver, got " + typeName(receiver)); + } + Path needle = scope.resolvePath(operands.get(1).getVariable()); + // The needle is a column, so it is escaped dynamically; a NULL needle is + // a missing attribute → CEL error → deny (fieldToFieldLike guards it). + return switch (op) { + case "contains" -> fieldToFieldLike(cb.literal(haystack), needle, true, true); + case "startsWith" -> fieldToFieldLike(cb.literal(haystack), needle, false, true); + case "endsWith" -> fieldToFieldLike(cb.literal(haystack), needle, true, false); + default -> throw new IllegalArgumentException( + "Unsupported string-match operator: " + op); + }; + } + } + // Detect leaf comparisons where one side is an 'add' expression (e.g. string // concatenation: `aString == "prefix:" + R.attr.id`). We fold constants and solve for // the field side when possible — same algorithm as the Prisma adapter. @@ -483,6 +559,31 @@ private static String typeName(Object o) { return o == null ? "null" : o.getClass().getSimpleName(); } + /** + * The constant value of a string-match RECEIVER operand: a plain VALUE, or an + * {@code add(value, value)} concatenation folded to its constant. Returns {@code null} + * when the operand is not a constant (e.g. a variable or a field-bearing expression), + * in which case the caller falls through to the ordinary leaf paths. + */ + private static Object constantReceiverOrNull(Operand o) { + return switch (o.getNodeCase()) { + case VALUE -> PlanValues.protoValueToJava(o.getValue()); + case EXPRESSION -> { + PlanResourcesFilter.Expression e = o.getExpression(); + if ("add".equals(e.getOperator()) + && e.getOperandsCount() == 2 + && e.getOperands(0).getNodeCase() == Operand.NodeCase.VALUE + && e.getOperands(1).getNodeCase() == Operand.NodeCase.VALUE) { + yield PlanValues.foldAdd( + PlanValues.protoValueToJava(e.getOperands(0).getValue()), + PlanValues.protoValueToJava(e.getOperands(1).getValue())); + } + yield null; + } + default -> null; + }; + } + /** * Compare two mapped columns directly (eq/ne/lt/gt/le/ge) or pattern-match one column * against another (contains/startsWith/endsWith). Operand source order is preserved — @@ -602,10 +703,24 @@ private Predicate defaultLeaf(String op, Path path, Object value) { * {@code number_value}). Emitting the arithmetic (rather than solving algebraically) * also means multiplication/division by negative constants needs no inequality flipping. * + *

DOUBLE space must be enforced explicitly, because DB decimal arithmetic is not + * IEEE double arithmetic (see {@link #resolveNumericOperand}): columns are CAST, plan + * constants are folded in Java or bound as double parameters, and pure-constant + * comparisons are evaluated statically in Java (full CEL fidelity, Infinity/NaN + * included). + * *

{@code mod} stays unsupported: CEL {@code %} has no double overload, so on * attribute values it always errors (deny) — translating it to SQL {@code MOD} would * fabricate rows the PDP denies. * + *

{@link OperatorFunction} overrides win here like on every other scalar path when + * the comparison has a plan constant on one side: the arithmetic SQL expression is + * passed as the field argument and the folded constant (always a {@link Double} — the + * arithmetic path is double-space end to end) as the value. Expression-vs-expression + * comparisons (arithmetic against arithmetic or against another column) have no + * (field, value) pair and are not consulted — the same exclusion as field-to-field + * comparisons. + * * @return the predicate, or {@code null} if this comparison involves no arithmetic * expression or the shape is owned by the {@code add} fold/solve path (which * also handles string concatenation and the override hooks) @@ -624,17 +739,58 @@ private Predicate tryArithmeticComparison(String op, List operands, Sco || addFoldSolveOwns(op, operands.get(1), operands.get(0))) { return null; } - jakarta.persistence.criteria.Expression left = - resolveNumericExpression(operands.get(0), scope); - jakarta.persistence.criteria.Expression right = - resolveNumericExpression(operands.get(1), scope); + NumericOperand left = resolveNumericOperand(operands.get(0), scope); + NumericOperand right = resolveNumericOperand(operands.get(1), scope); + + // Both sides folded to constants (e.g. ternary substitution producing + // gt(add(1.0, 2.0), 4.0)) — evaluate statically with IEEE semantics. + if (left instanceof NumericOperand.Constant lc + && right instanceof NumericOperand.Constant rc) { + return constantComparison(op, lc.value(), rc.value()); + } + // Keep the SQL side on the left (mirroring the operator) so a constant right side + // can bind through the plain-Number overloads. Normalization usually guarantees + // this already, but an expression that FOLDS to a constant (add(1.0, 2.0)) ranks + // as an expression and can still arrive first. + if (left instanceof NumericOperand.Constant) { + NumericOperand tmp = left; + left = right; + right = tmp; + op = NormalizedBinary.mirror(op); + } + jakarta.persistence.criteria.Expression lhs = + ((NumericOperand.Sql) left).expr(); + + if (right instanceof NumericOperand.Constant rc) { + OperatorFunction override = overrides.get(op); + if (override != null) { + return override.apply(cb, lhs, rc.value()); + } + // Plain-value overloads bind the constant as a genuine double PARAMETER; a + // cb.literal would inline `0.3`, which H2/Postgres type as exact NUMERIC and + // drag the comparison out of IEEE space (see resolveNumericOperand). + double v = rc.value(); + return switch (op) { + case "eq" -> cb.equal(lhs, v); + case "ne" -> cb.notEqual(lhs, v); + case "lt" -> cb.lt(lhs, v); + case "gt" -> cb.gt(lhs, v); + case "le" -> cb.le(lhs, v); + case "ge" -> cb.ge(lhs, v); + default -> throw new IllegalArgumentException( + "Unsupported arithmetic comparison operator: " + op); + }; + } + + jakarta.persistence.criteria.Expression rhs = + ((NumericOperand.Sql) right).expr(); return switch (op) { - case "eq" -> cb.equal(left, right); - case "ne" -> cb.notEqual(left, right); - case "lt" -> cb.lt(left, right); - case "gt" -> cb.gt(left, right); - case "le" -> cb.le(left, right); - case "ge" -> cb.ge(left, right); + case "eq" -> cb.equal(lhs, rhs); + case "ne" -> cb.notEqual(lhs, rhs); + case "lt" -> cb.lt(lhs, rhs); + case "gt" -> cb.gt(lhs, rhs); + case "le" -> cb.le(lhs, rhs); + case "ge" -> cb.ge(lhs, rhs); default -> throw new IllegalArgumentException( "Unsupported arithmetic comparison operator: " + op); }; @@ -671,15 +827,52 @@ private static boolean addFoldSolveOwns(String op, Operand candidate, Operand ot } /** - * Resolve a comparison operand to a numeric SQL expression in double space: - * variable → column cast to double; value → double literal; nested - * add/sub/mult/div → recursive {@code cb.sum}/{@code diff}/{@code prod}/{@code quot}. + * A resolved arithmetic operand: either a pure-constant subtree folded in Java — + * genuine IEEE double semantics, exactly matching CEL, including division by zero + * yielding ±Infinity/NaN — or a SQL expression forced into double space. */ - private jakarta.persistence.criteria.Expression resolveNumericExpression( - Operand operand, Scope scope) { + private sealed interface NumericOperand { + record Constant(double value) implements NumericOperand {} + record Sql(jakarta.persistence.criteria.Expression expr) + implements NumericOperand {} + } + + /** + * Resolve a comparison operand to double space. DB decimal arithmetic is NOT IEEE + * double arithmetic: H2 (and Postgres) type a bare {@code 0.1} literal as exact + * NUMERIC and evaluate {@code intCol * 0.1} decimally, so {@code aNumber * 0.1 == 0.3} + * matched rows the PDP (IEEE: {@code 0.30000000000000004}) denies. Verified against + * H2 2.3: only {@code CAST(col AS DOUBLE) * CAST(0.1 AS DOUBLE)} diverges from + * {@code 0.3}; {@code Expression.as(Double.class)} renders NO SQL cast (it is a type + * marker only) and {@code cb.toDouble(literal)} elides the cast on a node already + * Double-typed, both leaving the arithmetic decimal. Therefore: + *

    + *
  • columns go through {@code cb.toDouble} (renders {@code cast(col as float(53))});
  • + *
  • constant subtrees fold in Java ({@link NumericOperand.Constant});
  • + *
  • constants mixed into SQL arithmetic bind through the plain-{@code Number} + * CriteriaBuilder overloads, which emit genuine double-typed bind parameters + * instead of decimal literals.
  • + *
+ * + *

Division guard: SQL raises an error on a zero divisor — a data-dependent runtime + * failure of the WHOLE query — while CEL double division is defined (±Infinity, or NaN + * for 0/0). Portable Infinity semantics are not expressible in SQL, so a column + * divisor is wrapped in {@code NULLIF(d, 0)}: zero-divisor rows become UNKNOWN → + * EXCLUDED. Documented divergence, under-inclusive in the safe direction: CEL would + * ALLOW rows where the comparison against ±Infinity holds (e.g. {@code x/0 > 1} with + * {@code x > 0}); the adapter denies them, and the query survives. For 0/0 CEL yields + * NaN, whose comparisons are all false (deny) — the exclusion matches exactly. + * Constant divisors are decided statically: non-zero skips the guard, zero collapses + * the division to a NULL literal (UNKNOWN for every row). + */ + private NumericOperand resolveNumericOperand(Operand operand, Scope scope) { switch (operand.getNodeCase()) { case VARIABLE -> { - return scope.resolvePath(operand.getVariable()).as(Double.class); + @SuppressWarnings("unchecked") + jakarta.persistence.criteria.Expression path = + (jakarta.persistence.criteria.Expression) + scope.resolvePath(operand.getVariable()); + return new NumericOperand.Sql(cb.toDouble(path)); } case VALUE -> { Object v = PlanValues.protoValueToJava(operand.getValue()); @@ -688,7 +881,7 @@ private jakarta.persistence.criteria.Expression resolveNumericExpression "Arithmetic comparison requires numeric operands, got " + typeName(v)); } - return cb.literal(n.doubleValue()); + return new NumericOperand.Constant(n.doubleValue()); } case EXPRESSION -> { PlanResourcesFilter.Expression expr = operand.getExpression(); @@ -707,18 +900,20 @@ private jakarta.persistence.criteria.Expression resolveNumericExpression if (expr.getOperandsCount() != 2) { throw new IllegalArgumentException(op + " requires exactly 2 operands"); } - jakarta.persistence.criteria.Expression l = - resolveNumericExpression(expr.getOperands(0), scope); - jakarta.persistence.criteria.Expression r = - resolveNumericExpression(expr.getOperands(1), scope); - return switch (op) { - case "add" -> cb.sum(l, r); - case "sub" -> cb.diff(l, r); - case "mult" -> cb.prod(l, r); - case "div" -> cb.quot(l, r).as(Double.class); - default -> throw new IllegalArgumentException( - "Unsupported arithmetic operator: " + op); - }; + NumericOperand l = resolveNumericOperand(expr.getOperands(0), scope); + NumericOperand r = resolveNumericOperand(expr.getOperands(1), scope); + if (l instanceof NumericOperand.Constant lc + && r instanceof NumericOperand.Constant rc) { + return new NumericOperand.Constant(switch (op) { + case "add" -> lc.value() + rc.value(); + case "sub" -> lc.value() - rc.value(); + case "mult" -> lc.value() * rc.value(); + case "div" -> lc.value() / rc.value(); // IEEE: ±Infinity, 0/0 = NaN + default -> throw new IllegalArgumentException( + "Unsupported arithmetic operator: " + op); + }); + } + return new NumericOperand.Sql(arithmeticSql(op, l, r)); } default -> throw new IllegalArgumentException( "Unexpected operand type in arithmetic comparison: " @@ -726,6 +921,49 @@ private jakarta.persistence.criteria.Expression resolveNumericExpression } } + /** + * Emit one SQL arithmetic node; at least one side is a SQL expression. Constants go + * through the plain-{@code Number} overloads (double bind parameters — see + * {@link #resolveNumericOperand}). + */ + private jakarta.persistence.criteria.Expression arithmeticSql( + String op, NumericOperand l, NumericOperand r) { + jakarta.persistence.criteria.Expression le = + l instanceof NumericOperand.Sql s ? s.expr() : null; + jakarta.persistence.criteria.Expression re = + r instanceof NumericOperand.Sql s ? s.expr() : null; + Double lc = l instanceof NumericOperand.Constant c ? c.value() : null; + Double rc = r instanceof NumericOperand.Constant c ? c.value() : null; + return switch (op) { + case "add" -> le == null ? cb.sum(lc, re) + : re == null ? cb.sum(le, rc) : cb.sum(le, re); + case "sub" -> le == null ? cb.diff(lc, re) + : re == null ? cb.diff(le, rc) : cb.diff(le, re); + case "mult" -> le == null ? cb.prod(lc, re) + : re == null ? cb.prod(le, rc) : cb.prod(le, re); + case "div" -> divisionSql(le, lc, re, rc); + default -> throw new IllegalArgumentException( + "Unsupported arithmetic operator: " + op); + }; + } + + /** Division with the NULLIF zero-divisor guard (see {@link #resolveNumericOperand}). */ + private jakarta.persistence.criteria.Expression divisionSql( + jakarta.persistence.criteria.Expression le, Double lc, + jakarta.persistence.criteria.Expression re, Double rc) { + if (rc != null) { + // Constant divisor, numerator is a SQL expression (both-constant subtrees + // fold before reaching here). Zero → UNKNOWN for every row; non-zero → no + // guard needed. + if (rc == 0.0) { + return cb.nullLiteral(Double.class); + } + return cb.quot(le, rc).as(Double.class); + } + jakarta.persistence.criteria.Expression guarded = cb.nullif(re, 0.0); + return (lc != null ? cb.quot(lc, guarded) : cb.quot(le, guarded)).as(Double.class); + } + // -- add (fold + solve for string concat / numeric translation) -- /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ @@ -788,8 +1026,14 @@ private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression Object solved = PlanValues.solveAdd(otherValue, addConst, fieldIsLeft); if (solved == null) { // No solution exists (e.g. "projects:123" == "users:" + R.id can never be true). - // eq → always-false; ne → always-true. - return "eq".equals(op) ? cb.disjunction() : cb.conjunction(); + // eq → always-false. ne is NOT always-true: a missing attribute makes the + // concatenation a CEL evaluation error ("users:" + null) → deny, so NULL rows + // must stay excluded — IS NOT NULL, not an unconditional 1=1 (which leaked + // exactly the rows the PDP denies). + if ("eq".equals(op)) { + return cb.disjunction(); + } + return cb.isNotNull(scope.resolvePath(fieldOp.getVariable())); } Path path = scope.resolvePath(fieldOp.getVariable()); return applyLeaf(op, path, solved); @@ -844,10 +1088,10 @@ private Predicate handleIn(List rawOperands, Scope scope) { String var = fieldOp.getVariable(); Object val = PlanValues.protoValueToJava(valueOp.getValue()); - AttributeMapping mapping = scope.resolveMapping(var); - if (mapping instanceof AttributeMapping.Relation rel) { + Scope.ResolvedRelation relRef = scope.resolveRelation(var); + if (relRef != null) { List values = (val instanceof List l) ? l : List.of(val); - return collectionContainsAny(scope, rel, values); + return collectionContainsAny(scope, relRef, values); } Path path = scope.resolvePath(var); @@ -883,9 +1127,9 @@ private Predicate handleHasIntersection(List rawOperands, Scope scope) Object val = PlanValues.protoValueToJava(second.getValue()); List values = (val instanceof List l) ? l : List.of(val); - AttributeMapping mapping = scope.resolveMapping(var); - if (mapping instanceof AttributeMapping.Relation rel) { - return collectionContainsAny(scope, rel, values); + Scope.ResolvedRelation relRef = scope.resolveRelation(var); + if (relRef != null) { + return collectionContainsAny(scope, relRef, values); } Path path = scope.resolvePath(var); // hasIntersection(field, []) is always false; avoid a dialect-dependent empty `IN ()`. @@ -947,35 +1191,50 @@ private Predicate handleMapIntersection(PlanResourcesFilter.Expression mapExpr, } String memberField = Scope.extractLambdaSuffix(projection.getVariable(), lambdaVar.getVariable()); - // Check whether the collection path resolves through one Relation or a chain. - // A chain (e.g. "request.resource.attr.categories.subCategories") emits nested - // EXISTS subqueries — one per hop. - if (scope instanceof Scope.RootScope rootScope) { - Scope.RelationChain chain = Scope.resolveRelationChain(rootScope.mapper(), collectionVar); - if (chain != null && !chain.relations().isEmpty()) { - AttributeMapping.Relation tailRel = chain.relations().get(chain.relations().size() - 1); - return chainedExistsSubquery(scope, chain.relations(), (sub, joinFrom, correlated) -> - Scope.memberPath(joinFrom, tailRel, memberField).in(values)); - } + // Resolve the collection to its owner-anchored join chain. Single Relations and + // dotted chains ("request.resource.attr.categories.subCategories") share one path: + // the subquery correlates the OWNING From and joins through every hop, so the + // projection ranges over the flattened tail elements. + Scope.ResolvedRelation ref = scope.resolveRelation(collectionVar); + if (ref == null) { + scope.resolveMapping(collectionVar); // throws "Unknown attribute" when unmapped + throw new IllegalArgumentException( + "map can only be applied to a collection mapped as Relation: " + collectionVar); } + return mapIntersectionWithNullGuard( + existsSubquery(scope, ref, (sub, tailJoin, rebased) -> + Scope.memberPath(tailJoin, ref.tail(), memberField).in(values)), + () -> existsSubquery(scope, ref, (sub, tailJoin, rebased) -> + cb.isNull(Scope.memberPath(tailJoin, ref.tail(), memberField)))); + } - AttributeMapping mapping = scope.resolveMapping(collectionVar); - if (mapping instanceof AttributeMapping.Relation rel) { - return existsSubquery(scope, rel, (sub, joinFrom, correlated) -> - Scope.memberPath(joinFrom, rel, memberField).in(values)); - } - throw new IllegalArgumentException( - "map can only be applied to a collection mapped as Relation: " + collectionVar); + /** + * CEL {@code map()} has no error absorption: a NULL projected column is a missing element + * attribute, so the whole {@code hasIntersection(map(...), values)} is an evaluation + * error (deny) even when another element would intersect. Truth table: + *

    + *
  • no NULL projection → {@code (in AND TRUE) OR (FALSE AND UNKNOWN)} = in-EXISTS
  • + *
  • ≥1 NULL projection → {@code (in AND FALSE) OR (TRUE AND UNKNOWN)} = UNKNOWN (deny)
  • + *
+ * The null-witness EXISTS is supplied as a factory and built fresh per occurrence + * (Hibernate 6 negation is stateful — see {@link #negate}); {@code IS NULL} itself is + * two-valued, so both EXISTS legs are safe to compose. + */ + private Predicate mapIntersectionWithNullGuard(Predicate inExists, + Supplier nullProjectionExists) { + return cb.or( + cb.and(inExists, negate(nullProjectionExists.get())), + cb.and(nullProjectionExists.get(), unknownPredicate())); } - private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relation rel, List values) { + private Predicate collectionContainsAny(Scope scope, Scope.ResolvedRelation ref, List values) { // Intersection with an empty value set is always false — and an EXISTS wrapping an // empty `IN ()` is dialect-dependent — so short-circuit before building the subquery. if (values.isEmpty()) { return cb.disjunction(); } - return existsSubquery(outerScope, rel, (sub, joinFrom, correlated) -> { - Path field = Scope.memberPath(joinFrom, rel, null); + return existsSubquery(scope, ref, (sub, tailJoin, rebased) -> { + Path field = Scope.memberPath(tailJoin, ref.tail(), null); if (values.size() == 1) { return cb.equal(field, values.get(0)); } @@ -988,19 +1247,50 @@ private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relat /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ private Predicate trySizeComparison(String op, List operands, Scope scope) { PlanResourcesFilter.Expression sizeExpr = null; - Long numValue = null; + Double numRaw = null; for (Operand o : operands) { if (o.getNodeCase() == Operand.NodeCase.EXPRESSION && "size".equals(o.getExpression().getOperator())) { sizeExpr = o.getExpression(); } else if (o.getNodeCase() == Operand.NodeCase.VALUE) { Object v = PlanValues.protoValueToJava(o.getValue()); - if (v instanceof Number n) numValue = n.longValue(); + if (v instanceof Number n) numRaw = n.doubleValue(); } } - if (sizeExpr == null || numValue == null) { + if (sizeExpr == null || numRaw == null) { return null; } + + // Fractional thresholds: COUNT/LENGTH are integral, so a fractional constant f can + // never be hit exactly. Truncating (`>= 1.5` becoming `>= 1`) over-included rows + // the PDP denies. Correct integer-count semantics: + // eq f → always-false + // ne f → always-true (Field-mapping NULL caveat handled below: a NULL + // string column is a missing attribute → CEL error → deny) + // ge f/gt f → ge ceil(f) (the count being integral makes gt and ge coincide) + // le f/lt f → le floor(f) + // Integral thresholds keep the operator untouched. The always-true/false collapses + // flow through the same constant predicates the other static folds use + // (cb.conjunction()/cb.disjunction()), so the size(filter(...)) unknown-element + // machinery below still wraps them. + String cmpOp = op; + long numValue; + Boolean fractionalCollapse = null; // TRUE → always-true, FALSE → always-false + if (numRaw != Math.rint(numRaw)) { + switch (op) { + case "eq" -> fractionalCollapse = Boolean.FALSE; + case "ne" -> fractionalCollapse = Boolean.TRUE; + case "gt", "ge" -> cmpOp = "ge"; + case "lt", "le" -> cmpOp = "le"; + default -> throw new IllegalArgumentException( + "Unsupported size comparison operator: " + op); + } + numValue = "ge".equals(cmpOp) + ? (long) Math.ceil(numRaw) + : (long) Math.floor(numRaw); + } else { + numValue = numRaw.longValue(); + } List sizeOps = sizeExpr.getOperandsList(); if (sizeOps.size() != 1) { throw new IllegalArgumentException("Unsupported size() expression"); @@ -1032,46 +1322,74 @@ private Predicate trySizeComparison(String op, List operands, Scope sco } else { throw new IllegalArgumentException("Unsupported size() expression"); } - AttributeMapping mapping = scope.resolveMapping(var); - if (mapping instanceof AttributeMapping.Field) { + Scope.ResolvedRelation ref = scope.resolveRelation(var); + if (ref == null) { + AttributeMapping mapping = scope.resolveMapping(var); + if (!(mapping instanceof AttributeMapping.Field)) { + throw new IllegalArgumentException( + "size() requires a collection (Relation) mapping for " + var); + } // size(string) — CEL string length → LENGTH(column) N. if (lambdaBody != null) { throw new IllegalArgumentException( "size(filter(...)) requires a collection (Relation) mapping for " + var); } Path path = scope.resolvePath(var); - return compareCount(cb.length(path.as(String.class)), op, numValue.intValue()); - } - if (!(mapping instanceof AttributeMapping.Relation rel)) { - throw new IllegalArgumentException("size() requires a collection (Relation) mapping for " + var); + if (fractionalCollapse != null) { + // ne f is vacuously true only for a PRESENT string: a NULL column is a + // missing attribute → CEL error → deny, so it must stay excluded — + // IS NOT NULL, never an unconditional 1=1. eq f excludes everything. + return fractionalCollapse ? cb.isNotNull(path) : cb.disjunction(); + } + return compareCount(cb.length(path.as(String.class)), cmpOp, (int) numValue); } final Operand fBody = lambdaBody; final String fVar = lambdaVarName; - SubqueryBodyBuilder bodyBuilder = (sub, joinFrom, correlated) -> + SubqueryBodyBuilder bodyBuilder = (sub, tailJoin, rebased) -> fBody == null ? cb.conjunction() - : traverse(fBody, Scope.lambda(joinFrom, sub, rel, fVar, - Scope.rebase(scope, correlated, sub))); - - boolean nonEmpty = ("gt".equals(op) && numValue == 0L) || ("ge".equals(op) && numValue == 1L); - boolean empty = ("eq".equals(op) && numValue == 0L) - || ("le".equals(op) && numValue == 0L) - || ("lt".equals(op) && numValue == 1L); - - if (nonEmpty) { - return existsSubquery(scope, rel, bodyBuilder); - } - if (empty) { - return negate(existsSubquery(scope, rel, bodyBuilder)); - } - // Arbitrary N → correlated (SELECT COUNT(...)) N, same shape as exists_one. - Subquery sub = scope.parentQuery().subquery(Long.class); - From correlated = correlate(sub, scope.from()); - Join joinFrom = correlated.join(rel.joinAttribute()); - sub.select(cb.count(joinFrom)); - if (fBody != null) { - sub.where(bodyBuilder.build(sub, joinFrom, correlated)); - } - return compareCount(sub, op, numValue); + : traverse(fBody, Scope.lambda(tailJoin, sub, ref.tail(), fVar, rebased)); + + boolean nonEmpty = ("gt".equals(cmpOp) && numValue == 0L) + || ("ge".equals(cmpOp) && numValue == 1L); + boolean empty = ("eq".equals(cmpOp) && numValue == 0L) + || ("le".equals(cmpOp) && numValue == 0L) + || ("lt".equals(cmpOp) && numValue == 1L); + + Predicate base; + if (fractionalCollapse != null) { + // A Relation count is always defined (an empty join is count 0), so the + // fractional eq/ne collapse is unconditional here. Falls through to the + // size(filter(...)) unknown-element guard below so an erroring lambda body + // still denies the row. + base = fractionalCollapse ? cb.conjunction() : cb.disjunction(); + } else if (nonEmpty) { + base = existsSubquery(scope, ref, bodyBuilder); + } else if (empty) { + base = negate(existsSubquery(scope, ref, bodyBuilder)); + } else { + // Arbitrary N → correlated (SELECT COUNT(...)) N, same shape as exists_one. + // For a multi-hop chain the COUNT joins through every hop, so it counts the + // FLATTENED tail elements — the same element set the EXISTS shortcuts range over. + ChainSubquery cs = chainSubquery(Long.class, scope, ref); + cs.sub().select(cb.count(cs.tailJoin())); + if (fBody != null) { + cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); + } + base = compareCount(cs.sub(), cmpOp, numValue); + } + if (fBody == null) { + // size(collection) counts rows without evaluating a lambda — no element can be + // UNKNOWN, so the plain comparison is already exact. + return base; + } + // size(coll.filter(x, pred)): CEL filter has NO error absorption — any element whose + // predicate errors (NULL-derived UNKNOWN body) errors the whole expression (deny), + // even when the count comparison would otherwise hold. Same combinator as exists_one: + // ≥1 UNKNOWN element → (base AND FALSE) OR (TRUE AND UNKNOWN) = UNKNOWN (deny) + // no UNKNOWN element → (base AND TRUE) OR (FALSE AND …) = base + return cb.or( + cb.and(base, negate(unknownElementExists(scope, ref, bodyBuilder))), + cb.and(unknownElementExists(scope, ref, bodyBuilder), unknownPredicate())); } /** Compare a numeric size expression (COUNT subquery or LENGTH) against a constant. */ @@ -1091,6 +1409,31 @@ private > Predicate compareCount( // -- exists / exists_one / all / except / filter -- + /** + * Collection macros translate TRI-STATE to mirror CEL error semantics (per the cel-spec + * macro definitions; a NULL element column is a missing element attribute, so a lambda + * body touching it is a CEL evaluation error → deny): + *
    + *
  • {@code exists} — OR with error absorption: true if ANY element matches; error if + * none matches and at least one errors; false otherwise.
  • + *
  • {@code all} — AND with error absorption: false if ANY element fails; error if + * none fails and at least one errors; true otherwise.
  • + *
  • {@code exists_one} — errors if ANY element errors; else true iff exactly one + * matches.
  • + *
+ * ERROR maps to SQL UNKNOWN so the row stays excluded under BOTH polarities + * ({@code NOT(UNKNOWN) = UNKNOWN}). A plain EXISTS is not enough: an element whose body + * is UNKNOWN silently fails to match, collapsing the error case to FALSE — which + * {@code not(...)} flips to TRUE, an authorization leak. Building blocks: + * {@code EXISTS(elem WHERE body)} (true witness), {@code EXISTS(elem WHERE NOT body)} + * (false witness), {@link #unknownElementExists} (any UNKNOWN-body element) and + * {@link #unknownPredicate} (a constant SQL UNKNOWN to compose with). + * + *

{@code filter}/{@code except} in boolean position are kept consistent with the + * {@code exists} family. Cost note: the unknown machinery (two correlated COUNT + * subqueries) is always emitted — the attribute mapping carries no column-nullability + * metadata, so a NULL-free lambda body cannot be detected statically. + */ private Predicate handleCollectionOperator(String op, List operands, Scope scope) { if (operands.size() != 2) { throw new IllegalArgumentException(op + " requires exactly 2 operands"); @@ -1107,8 +1450,11 @@ private Predicate handleCollectionOperator(String op, List operands, Sc } String collectionVar = listOperand.getVariable(); - AttributeMapping mapping = scope.resolveMapping(collectionVar); - if (!(mapping instanceof AttributeMapping.Relation rel)) { + // Owner-anchored chain resolution: multi-hop chains join through every hop, and a + // relation referenced from inside a lambda anchors to the scope that owns it. + Scope.ResolvedRelation ref = scope.resolveRelation(collectionVar); + if (ref == null) { + scope.resolveMapping(collectionVar); // throws "Unknown attribute" when unmapped throw new IllegalArgumentException( op + " requires a Relation mapping for " + collectionVar); } @@ -1124,42 +1470,103 @@ private Predicate handleCollectionOperator(String op, List operands, Sc } String lambdaVarName = lambdaVar.getVariable(); + // Every invocation re-traverses the body, so each occurrence gets a fresh Predicate + // tree (Hibernate 6 negation is stateful — see negate()). + SubqueryBodyBuilder bodyBuilder = (sub, tailJoin, rebased) -> traverse(body, + Scope.lambda(tailJoin, sub, ref.tail(), lambdaVarName, rebased)); + SubqueryBodyBuilder negatedBodyBuilder = (sub, tailJoin, rebased) -> + negate(bodyBuilder.build(sub, tailJoin, rebased)); + return switch (op) { - case "exists", "filter" -> existsSubquery(scope, rel, - (sub, joinFrom, correlated) -> traverse(body, - Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub)))); - case "except" -> existsSubquery(scope, rel, - (sub, joinFrom, correlated) -> negate(traverse(body, - Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub))))); - case "all" -> negate(existsSubquery(scope, rel, - (sub, joinFrom, correlated) -> negate(traverse(body, - Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub)))))); + // exists (and filter): true-witness OR (unknown-element AND UNKNOWN). + // any TRUE element → TRUE OR … = TRUE (absorbed) + // no TRUE, ≥1 UNKNOWN element → FALSE OR (TRUE AND UNKNOWN) = UNKNOWN (deny) + // no TRUE, no UNKNOWN → FALSE OR (FALSE AND UNKNOWN) = FALSE + case "exists", "filter" -> cb.or( + existsSubquery(scope, ref, bodyBuilder), + cb.and(unknownElementExists(scope, ref, bodyBuilder), unknownPredicate())); + // except is "some element fails the body" — the exists table with the false + // witness in the true-witness seat; an UNKNOWN body is UNKNOWN under NOT too. + case "except" -> cb.or( + existsSubquery(scope, ref, negatedBodyBuilder), + cb.and(unknownElementExists(scope, ref, bodyBuilder), unknownPredicate())); + // all: NOT false-witness AND (NOT unknown-element OR UNKNOWN). + // any FALSE element → FALSE AND … = FALSE (absorbed) + // no FALSE, ≥1 UNKNOWN element → TRUE AND (FALSE OR UNKNOWN) = UNKNOWN (deny) + // no FALSE, no UNKNOWN → TRUE AND (TRUE OR UNKNOWN) = TRUE + case "all" -> cb.and( + negate(existsSubquery(scope, ref, negatedBodyBuilder)), + cb.or(negate(unknownElementExists(scope, ref, bodyBuilder)), unknownPredicate())); + // exists_one: (COUNT(body) = 1 AND NOT unknown-element) OR (unknown-element AND UNKNOWN). + // ≥1 UNKNOWN element → (… AND FALSE) OR (TRUE AND UNKNOWN) = UNKNOWN (deny) + // no UNKNOWN element → (COUNT=1 AND TRUE) OR (FALSE AND …) = COUNT=1 case "exists_one" -> { - Subquery sub = scope.parentQuery().subquery(Long.class); - From correlated = correlate(sub, scope.from()); - Join joinFrom = correlated.join(rel.joinAttribute()); - sub.select(cb.count(joinFrom)); - sub.where(traverse(body, - Scope.lambda(joinFrom, sub, rel, lambdaVarName, Scope.rebase(scope, correlated, sub)))); - yield cb.equal(sub, 1L); + ChainSubquery cs = chainSubquery(Long.class, scope, ref); + cs.sub().select(cb.count(cs.tailJoin())); + cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); + yield cb.or( + cb.and(cb.equal(cs.sub(), 1L), + negate(unknownElementExists(scope, ref, bodyBuilder))), + cb.and(unknownElementExists(scope, ref, bodyBuilder), unknownPredicate())); } default -> throw new IllegalArgumentException("Unsupported collection operator: " + op); }; } + /** + * TRUE iff the relation holds at least one element whose lambda body evaluates to SQL + * UNKNOWN (NULL-derived). Not expressible as a single EXISTS: inside a subquery WHERE an + * UNKNOWN body simply fails to match, so {@code EXISTS(body)} and {@code EXISTS(NOT body)} + * both skip exactly the rows to be detected. Counting closes the gap — an element is + * determined iff {@code body OR NOT body} matches it, therefore + * {@code COUNT(elem) > COUNT(elem WHERE body OR NOT body)} holds iff at least one element + * is UNKNOWN, including mixed collections where sibling elements are determined + * true/false. Both COUNTs never yield NULL, so the comparison itself is two-valued and + * safe to {@link #negate}. The body is translated fresh per occurrence (stateful + * negation — see {@link #negate}). + */ + private Predicate unknownElementExists(Scope scope, Scope.ResolvedRelation ref, + SubqueryBodyBuilder bodyBuilder) { + ChainSubquery total = chainSubquery(Long.class, scope, ref); + total.sub().select(cb.count(total.tailJoin())); + + ChainSubquery determined = chainSubquery(Long.class, scope, ref); + determined.sub().select(cb.count(determined.tailJoin())); + determined.sub().where(cb.or( + bodyBuilder.build(determined.sub(), determined.tailJoin(), determined.rebasedOuter()), + negate(bodyBuilder.build(determined.sub(), determined.tailJoin(), determined.rebasedOuter())))); + + return cb.greaterThan(total.sub(), determined.sub()); + } + + /** + * A constant SQL UNKNOWN: {@code 1 = NULL} (validated by the unknownBooleanConstantProbe + * unit test against Hibernate 6). Composes by three-valued logic exactly like CEL error + * absorption: {@code x AND UNKNOWN} is FALSE when x is FALSE (a false witness absorbs the + * error) and UNKNOWN when x is TRUE; {@code x OR UNKNOWN} is TRUE when x is TRUE and + * UNKNOWN when x is FALSE. Its negation is UNKNOWN as well, so predicates carrying it + * stay excluded under both polarities. + */ + private Predicate unknownPredicate() { + return cb.equal(cb.literal(1), cb.nullLiteral(Integer.class)); + } + @FunctionalInterface private interface SubqueryBodyBuilder { /** - * @param sub the subquery being built - * @param joinFrom the join over the Relation's collection inside the subquery - * @param correlated the outer entity correlated into the subquery — lambda bodies - * resolve non-lambda variables (e.g. {@code request.resource.attr.x}) - * against this so outer references stay legal JPA correlation paths + * @param sub the subquery being built + * @param tailJoin the join over the chain's TAIL Relation inside the subquery — + * for a single Relation, the join over its collection; for a + * multi-hop chain, the innermost join of the join chain + * @param rebasedOuter the enclosing scope re-rooted for use inside {@code sub} + * (see {@link Scope#rebaseAt}) — lambda bodies resolve + * non-lambda variables (e.g. {@code request.resource.attr.x}) + * through this so outer references stay legal correlation paths */ - Predicate build(Subquery sub, From joinFrom, From correlated); + Predicate build(Subquery sub, Join tailJoin, Scope rebasedOuter); } - /** Correlate the current scope's {@code From} into {@code sub}. */ + /** Correlate {@code outerFrom} (the relation owner's {@code From}) into {@code sub}. */ @SuppressWarnings("unchecked") private static From correlate(Subquery sub, From outerFrom) { if (outerFrom instanceof Root r) { @@ -1172,34 +1579,48 @@ private interface SubqueryBodyBuilder { } /** - * Build nested EXISTS subqueries for a chain of Relations: the outermost EXISTS joins the - * first Relation, an inner EXISTS correlates from that join through the next, and so on. - * The {@code bodyBuilder} produces the leaf predicate against the innermost join. + * A correlated subquery spanning a resolved relation chain: {@code sub} correlates the + * chain OWNER's {@code From} and joins through every hop to {@code tailJoin}, with + * {@code rebasedOuter} being the evaluation scope re-rooted inside {@code sub}. */ - private Predicate chainedExistsSubquery(Scope scope, - List chain, - SubqueryBodyBuilder bodyBuilder) { - if (chain.size() == 1) { - return existsSubquery(scope, chain.get(0), bodyBuilder); - } - return existsSubquery(scope, chain.get(0), (sub, joinFrom, correlated) -> { - // Recurse using an intermediate scope rooted at the current join + this subquery. - // The lambda variable name is internal-only — `$` is not a valid CEL identifier - // character, so this sentinel can never collide with a user-supplied lambda name. - AttributeMapping.Relation thisRel = chain.get(0); - Scope intermediate = Scope.lambda(joinFrom, sub, thisRel, "$$chain$$", - Scope.rebase(scope, correlated, sub)); - return chainedExistsSubquery(intermediate, chain.subList(1, chain.size()), bodyBuilder); - }); + private record ChainSubquery(Subquery sub, Join tailJoin, Scope rebasedOuter) {} + + /** + * Build the shared skeleton of every relation subquery. Two invariants fix the two + * join-anchoring failure modes: + *

    + *
  • the correlation anchor is {@code ref.owner().from()} — the {@code From} that + * OWNS the first relation attribute — never the evaluation scope's own + * {@code from()}, which inside a lambda is the lambda element join and does not + * hold outer relations like {@code request.resource.attr.tags};
  • + *
  • a multi-hop chain ({@code categories.subCategories}) joins THROUGH every hop + * off that anchor, so the subquery ranges over the flattened tail elements — + * joining only the tail attribute off the anchor would either fail at query-build + * time or silently query a same-named collection on the wrong entity.
  • + *
+ * EXISTS over the join chain and COUNT over {@code tailJoin} therefore express + * exists/in/hasIntersection membership and {@code size()} of the flattened union with + * the same element set, so the tri-state unknown-element machinery composes with chains + * unchanged (its COUNT subqueries traverse the identical chain). + */ + private ChainSubquery chainSubquery(Class resultType, Scope scope, + Scope.ResolvedRelation ref) { + Subquery sub = scope.parentQuery().subquery(resultType); + From correlated = correlate(sub, ref.owner().from()); + Join join = correlated.join(ref.chain().get(0).joinAttribute()); + for (int i = 1; i < ref.chain().size(); i++) { + join = join.join(ref.chain().get(i).joinAttribute()); + } + Scope rebased = Scope.rebaseAt(scope, ref.owner(), correlated, sub); + return new ChainSubquery<>(sub, join, rebased); } - private Predicate existsSubquery(Scope scope, AttributeMapping.Relation rel, SubqueryBodyBuilder bodyBuilder) { - Subquery sub = scope.parentQuery().subquery(Integer.class); - From correlated = correlate(sub, scope.from()); - Join joinFrom = correlated.join(rel.joinAttribute()); - sub.select(cb.literal(1)); - sub.where(bodyBuilder.build(sub, joinFrom, correlated)); - return cb.exists(sub); + private Predicate existsSubquery(Scope scope, Scope.ResolvedRelation ref, + SubqueryBodyBuilder bodyBuilder) { + ChainSubquery cs = chainSubquery(Integer.class, scope, ref); + cs.sub().select(cb.literal(1)); + cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); + return cb.exists(cs.sub()); } } } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java index 4a17b648..a212a0ef 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java @@ -62,6 +62,9 @@ class AdversarialConformanceTest { Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), Map.entry("request.resource.attr.aNumber", AttributeMapping.field("aNumber")), Map.entry("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")), + // probe additions: ISO-date string column + flattened struct member + Map.entry("request.resource.attr.createdBy", AttributeMapping.field("createdBy")), + Map.entry("request.resource.attr.obj.inner", AttributeMapping.field("aString")), Map.entry("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( "id", AttributeMapping.field("id"), "name", AttributeMapping.field("name") @@ -71,6 +74,20 @@ class AdversarialConformanceTest { "subCategories", AttributeMapping.relation("subCategories", Map.of( "name", AttributeMapping.field("name") )) + ))), + // Multi-hop chain probe (W1): mainCategory is a SINGLE nested object on the check + // side (every seed holds at most one category), so CEL evaluates dotted chains + // like R.attr.mainCategory.subCategories naturally — while the ADAPTER maps the + // same path through TWO collection hops (categories JOIN subCategories), pinning + // that chained variables join through every intermediate hop, never off the root. + Map.entry("request.resource.attr.mainCategory", AttributeMapping.relation("categories", Map.of( + "name", AttributeMapping.field("name"), + "subCategories", AttributeMapping.relation("subCategories", Map.of( + "name", AttributeMapping.field("name") + )), + // subNames: the same 2-hop chain but with a defaultMemberField, so plain + // `in` membership compares the flattened tail's name column. + "subNames", AttributeMapping.relation("subCategories", "name") ))) ); @@ -107,9 +124,26 @@ private record Seed(String id, boolean aBool, String aString, int aNumber, // b2/b3 are literal % and \ matches that only work when the escape is correct. new Seed("b1", true, "oneXtwo", 7, "one_two", List.of(), List.of()), new Seed("b2", false, "50%_off", 6, "%_o", List.of(), List.of()), - new Seed("b3", true, "back\\slash", -4, "k\\s", List.of(), List.of()) + new Seed("b3", true, "back\\slash", -4, "k\\s", List.of(), List.of()), + // Probe witness: a tag whose id equals its name (lambda-inner field-to-field). + new Seed("b4", false, "mirror", 8, "mirror", + List.of(new Tag("mirror", "mirror")), List.of()), + // NULL element columns: a NULL tag name is a missing element attribute on the + // check side, so lambda bodies touching it are CEL evaluation errors. b5 holds + // ONLY a NULL-name tag (no true/false witness → macro errors); b6 mixes a + // NULL-name tag with a "public" one (exists absorbs the error, all/exists_one + // and map() do not). + new Seed("b5", true, "nulltag", 9, "nt", + List.of(new Tag("t10a", null)), List.of()), + new Seed("b6", false, "mixed", 10, "mx", + List.of(new Tag("t11a", null), new Tag("t11b", "public")), List.of()) ); + /** Deterministic ISO instant per seed for the timestamp probe: split around 2025-01-01. */ + private static String isoFor(Seed s) { + return s.aNumber() >= 2 ? "2024-06-01T00:00:00Z" : "2026-06-01T00:00:00Z"; + } + private static GenericContainer cerbos; private static CerbosBlockingClient client; private static EntityManagerFactory emf; @@ -156,6 +190,7 @@ private static void seed() { r.setaString(s.aString()); r.setaNumber(s.aNumber()); r.setaOptionalString(s.aOptionalString()); + r.setCreatedBy(isoFor(s)); for (Tag tag : s.tags()) { r.addTag(tag.id(), tag.name()); } @@ -191,10 +226,11 @@ private static Resource asCheckResource(Seed s) { .withAttribute("aBool", AttributeValue.boolValue(s.aBool())) .withAttribute("aString", AttributeValue.stringValue(s.aString())) .withAttribute("aNumber", AttributeValue.doubleValue(s.aNumber())) + .withAttribute("createdBy", AttributeValue.stringValue(isoFor(s))) + .withAttribute("obj", AttributeValue.mapValue(Map.of( + "inner", AttributeValue.stringValue(s.aString())))) .withAttribute("tags", AttributeValue.listValue(s.tags().stream() - .map(t -> AttributeValue.mapValue(Map.of( - "id", AttributeValue.stringValue(t.id()), - "name", AttributeValue.stringValue(t.name())))) + .map(AdversarialConformanceTest::asTagAttribute) .toList())) .withAttribute("categories", AttributeValue.listValue(s.subCategoryNames().stream() .map(subName -> AttributeValue.mapValue(Map.of( @@ -208,9 +244,34 @@ private static Resource asCheckResource(Seed s) { if (s.aOptionalString() != null) { r = r.withAttribute("aOptionalString", AttributeValue.stringValue(s.aOptionalString())); } + // mainCategory mirrors the row's single category as ONE nested object (the seeder + // creates at most one category per seed), so direct dotted-chain CEL expressions + // evaluate cleanly; rows without a category get NO attribute — a CEL missing-attr + // error (deny), matching the adapter's empty join chain excluding the row. + if (!s.subCategoryNames().isEmpty()) { + r = r.withAttribute("mainCategory", AttributeValue.mapValue(Map.of( + "name", AttributeValue.stringValue("business"), + "subCategories", AttributeValue.listValue(s.subCategoryNames().stream() + .map(n -> AttributeValue.mapValue(Map.of( + "name", AttributeValue.stringValue(n)))) + .toList()), + "subNames", AttributeValue.listValue(s.subCategoryNames().stream() + .map(AttributeValue::stringValue) + .toList())))); + } return r; } + /** A NULL tag name in the DB is a missing element attribute on the check side. */ + private static AttributeValue asTagAttribute(Tag t) { + Map attrs = new LinkedHashMap<>(); + attrs.put("id", AttributeValue.stringValue(t.id())); + if (t.name() != null) { + attrs.put("name", AttributeValue.stringValue(t.name())); + } + return AttributeValue.mapValue(attrs); + } + private static List oracleAllowedIds(String action) { return SEEDS.stream() .filter(s -> client.check(principal(), asCheckResource(s), action).isAllowed(action)) @@ -261,6 +322,30 @@ private static List adapterFilteredIds(String action) { "f2f-contains", "f2f-startswith", "f2f-endswith", "arith-add", "arith-vf", "arith-sub", "arith-mult-neg", "arith-div", "arith-div-frac", "arith-both", + // clean-room probes (GROUP A) + "p-ternary-in-exists", "p-arith-in-lambda", "p-lambda-inner-f2f", + "p-lambda-f2f-like", "p-size-nested", + "p-ternary-of-ternaries", "p-ternary-vs-ternary", "p-ternary-under-all", + "p-hasintersection-map", "p-deep-nest", + "p-in-null-single", "p-in-null-multi", "p-startswith-concat", + // clean-room probes (GROUP B). Shapes that THROW are covered by + // unsupportedShapesThrow below. Known divergences excluded from the oracle run: + // p-has — planner folds has(unknown attr) to KIND_ALWAYS_ALLOWED, so the + // adapter returns all rows while check() denies NULL rows. + // p-double-frac is IN the run: the adapter forces IEEE double space (CAST columns, + // double bind parameters), so 3*0.1 == 0.3 is false in SQL exactly as in CEL. + "p-struct", "p-not-exists-empty", "p-not-ternary-null", "p-double-frac", + // NULL element columns under collection macros (b5/b6 witnesses) + "n-not-exists-one-null", "n-all-mixed-null", "n-not-all-null", "n-not-all-absorb", + // constant-receiver string matches (the constant is the haystack; escaping + // discriminators live in the a2/a4/a7 seeds) + "cr-contains", "cr-startswith", "cr-endswith", "cr-startswith-concat", + // arithmetic + size edges: zero column divisor (NaN → deny), fractional count + // threshold (only ordering ops compile in CEL — int vs double eq/ne does not) + "cr-div-zero", "cr-size-frac-ge", + // multi-hop relation chains via DIRECT dotted syntax (W1) and a root relation + // subquery anchored from inside a lambda body (W2) + "w1-exists-chain", "w1-size-chain", "w1-in-chain", "w2-outer-relation", }) void adapterMatchesCheckOracle(String action) { List oracle = oracleAllowedIds(action); @@ -269,6 +354,22 @@ void adapterMatchesCheckOracle(String action) { "adapter result diverges from check-API oracle for action '" + action + "'"); } + /** + * Probe shapes the adapter does not support: the translation must fail loudly (never a + * silently-wrong filter). Messages pinned so a regression to silent acceptance is caught. + */ + @ParameterizedTest(name = "{0}") + @org.junit.jupiter.params.provider.CsvSource({ + "p-timestamp, Unexpected timestamp() expression in leaf operand of lt", + "p-matches, Unsupported operator: matches", + "p-index, Unexpected get-field() expression in leaf operand of eq", + }) + void unsupportedShapesThrow(String action, String expectedMessage) { + IllegalArgumentException ex = org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, () -> adapterFilteredIds(action)); + assertEquals(expectedMessage, ex.getMessage()); + } + @Test void oracleIsNotDegenerate() { // Guard the guard: at least one action must produce a non-empty, non-total oracle set, diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 2a87a86d..9db54c4c 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -763,9 +763,13 @@ void threeLevelNestingIsCorrelatedNotCrossJoined() { .reduce("", (a, b) -> a.length() >= b.length() ? a : b) .toLowerCase(); assertFalse(sql.isEmpty(), "expected a SELECT with EXISTS to be captured"); - // One correlated EXISTS per relation hop (categories -> subCategories -> labels). - assertEquals(3, countOccurrences(sql, "exists"), - "expected three nested EXISTS subqueries, SQL was:\n" + sql); + // At least one correlated EXISTS per relation hop (categories -> subCategories -> + // labels). The tri-state macro translation re-translates each hop's body inside its + // unknown-element COUNT subqueries, so the total EXISTS count exceeds three — the + // nesting guarantee is the lower bound plus the cross-join ban and the exact result + // rows asserted above. + assertTrue(countOccurrences(sql, "exists") >= 3, + "expected at least three nested EXISTS subqueries, SQL was:\n" + sql); // Correlated subqueries must not collapse into a cartesian product. assertFalse(sql.contains("cross join"), "nested correlation degraded into a cross join, SQL was:\n" + sql); diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index 362cdc37..629050ef 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -139,10 +139,16 @@ private static int runCount(Operand condition) { /** {@link #runCount(Operand)} with per-operator overrides. */ private static int runCount(Operand condition, Map overrides) { + return runCount(condition, MAPPER, overrides); + } + + /** {@link #runCount(Operand)} against a caller-supplied attribute mapper. */ + private static int runCount(Operand condition, Map mapper, + Map overrides) { PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, condition); Result result = - SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER, overrides); + SpringDataQueryPlanAdapter.toSpecification(resp, mapper, overrides); assertInstanceOf(Result.Conditional.class, result); Specification spec = ((Result.Conditional) result).specification(); @@ -416,6 +422,105 @@ void sizeValueFirstWithArbitraryNIsMirrored() { } } + // -- Fractional size() thresholds: COUNT/LENGTH are integral, so a fractional constant f + // can never be hit exactly. Correct semantics: eq → always-false; ne → always-true (but a + // NULL string column is a missing attribute → CEL error → deny); ge/gt f → ge ceil(f); + // le/lt f → le floor(f). Truncation (`>= 1.5` becoming `>= 1`) over-included. + + @Nested + class FractionalSizeThresholds { + + private ResourceEntity seeded() { + ResourceEntity r = new ResourceEntity("size-frac-seed-1"); + r.setOwnedBy(new java.util.ArrayList<>(java.util.List.of("user1", "user2"))); + return r; + } + + private Operand sizeCmp(String op, double threshold) { + return exprOp(op, + exprOp("size", var("request.resource.attr.ownedBy")), + nval(threshold)); + } + + @Test + void eqFractionalIsAlwaysFalse() { + // size == 2.5 can never hold for an integral count; truncation made it eq 2. + withResource(seeded(), () -> assertEquals(0, runCount(sizeCmp("eq", 2.5)))); + } + + @Test + void neFractionalIsAlwaysTrue() { + // size != 2.5 always holds; truncation made it ne 2 (false for the seeded row). + withResource(seeded(), () -> assertEquals(1, runCount(sizeCmp("ne", 2.5)))); + } + + @Test + void geFractionalRoundsUp() { + // size >= 2.5 ⇔ size >= 3; truncation made it >= 2 (over-inclusive). + withResource(seeded(), () -> { + assertEquals(0, runCount(sizeCmp("ge", 2.5))); + assertEquals(1, runCount(sizeCmp("ge", 1.5))); + }); + } + + @Test + void gtFractionalRoundsUp() { + // gt f ⇔ ge ceil(f) for integral counts. + withResource(seeded(), () -> { + assertEquals(1, runCount(sizeCmp("gt", 1.5))); + assertEquals(0, runCount(sizeCmp("gt", 2.5))); + }); + } + + @Test + void ltFractionalRoundsDown() { + // size < 2.5 ⇔ size <= 2; truncation made it lt 2 (under-inclusive). + withResource(seeded(), () -> { + assertEquals(1, runCount(sizeCmp("lt", 2.5))); + assertEquals(0, runCount(sizeCmp("lt", 1.5))); + }); + } + + @Test + void leFractionalRoundsDown() { + withResource(seeded(), () -> { + assertEquals(0, runCount(sizeCmp("le", 1.5))); + assertEquals(1, runCount(sizeCmp("le", 2.5))); + }); + } + + @Test + void fractionalEmptinessShortcutsStillRoute() { + // ge 0.5 ⇔ ge 1 → EXISTS; lt 0.5 ⇔ le 0 → NOT EXISTS. + withResource(seeded(), () -> { + assertEquals(1, runCount(sizeCmp("ge", 0.5))); + assertEquals(0, runCount(sizeCmp("lt", 0.5))); + }); + } + + @Test + void stringSizeFractionalNeExcludesNullColumn() { + // size(string) != 1.5 is vacuously true for any PRESENT string, but a NULL + // column is a missing attribute → CEL error → deny. Always-true would leak it. + Operand cond = exprOp("ne", + exprOp("size", var("request.resource.attr.aOptionalString")), + nval(1.5)); + ResourceEntity withValue = new ResourceEntity("size-frac-str-1"); + withValue.setaOptionalString("ab"); + ResourceEntity withNull = new ResourceEntity("size-frac-str-2"); + withNull.setaOptionalString(null); + withResource(withValue, () -> withResource(withNull, () -> + assertEquals(1, runCount(cond)))); + // eq fractional over a string length is always false, NULL or not. + Operand eqCond = exprOp("eq", + exprOp("size", var("request.resource.attr.aOptionalString")), + nval(1.5)); + ResourceEntity another = new ResourceEntity("size-frac-str-3"); + another.setaOptionalString("ab"); + withResource(another, () -> assertEquals(0, runCount(eqCond))); + } + } + @Test void existsOnNestedRelation() { assertEquals(0, runCount(exprOp("exists", @@ -620,6 +725,32 @@ void addNoSolutionEqProducesImpossibleFilter() { assertEquals(0, runCount(cond)); } + @Test + void addNoSolutionNeExcludesNullRows() { + // ne("abc", add("users:", R.attr.aOptionalString)): no field value can make the + // concatenation equal "abc", BUT a missing attribute makes `"users:" + null` a CEL + // evaluation error → deny. An always-true collapse would leak the NULL row; the + // correct translation is IS NOT NULL (non-NULL rows in, NULL rows out). + Operand neCond = exprOp("ne", + sval("abc"), + exprOp("add", sval("users:"), var("request.resource.attr.aOptionalString"))); + Operand eqCond = exprOp("eq", + sval("abc"), + exprOp("add", sval("users:"), var("request.resource.attr.aOptionalString"))); + + ResourceEntity withValue = new ResourceEntity("ne-add-1"); + withValue.setaOptionalString("x"); + ResourceEntity withNull = new ResourceEntity("ne-add-2"); + withNull.setaOptionalString(null); + + withResource(withValue, () -> withResource(withNull, () -> { + // Only the non-NULL row survives ne; the NULL row is a CEL error → deny. + assertEquals(1, runCount(neCond)); + // eq stays always-false: neither row matches (NULL row denied there too). + assertEquals(0, runCount(eqCond)); + })); + } + // -- DeMorgan / negated operator wrappers (PR #222) -- @Nested @@ -907,6 +1038,196 @@ void sizeOfFilterCountsMatchingElements() { } } + // -- NULL element columns under collection macros (three-valued lambda bodies) -- + // CEL semantics (cel-spec macro definitions; a NULL element column is a missing attribute, + // so touching it is an evaluation error → deny): + // exists — OR with error absorption: true if ANY element is true; error if no true + // and ≥1 error; false otherwise. + // all — AND with error absorption: false if ANY element is false; error if no + // false and ≥1 error; true otherwise. + // exists_one — errors if ANY element errors; else true iff exactly one matches. + // The SQL translation must map ERROR to UNKNOWN (excluded under BOTH polarities), never + // FALSE — NOT(FALSE) = TRUE would leak rows the PDP denies. + + @Nested + class CollectionMacroNullElements { + + private Operand existsPublic() { + return exprOp("exists", var("request.resource.attr.tags"), + lambda("t", exprOp("eq", var("t.name"), sval("public")))); + } + + private Operand allNotX() { + return exprOp("all", var("request.resource.attr.tags"), + lambda("t", exprOp("ne", var("t.name"), sval("x")))); + } + + private Operand existsOnePublic() { + return exprOp("exists_one", var("request.resource.attr.tags"), + lambda("t", exprOp("eq", var("t.name"), sval("public")))); + } + + /** + * Probe for the UNKNOWN boolean constant the macro translations compose with: + * {@code 1 = NULL} must render as a genuinely UNKNOWN predicate in Hibernate 6 — + * matching no rows under EITHER polarity (NOT(UNKNOWN) = UNKNOWN). + */ + @Test + void unknownBooleanConstantProbe() { + withResource(new ResourceEntity("null-elem-probe"), () -> { + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery positive = cb.createQuery(Long.class); + positive.select(cb.count(positive.from(ResourceEntity.class))); + positive.where(cb.equal(cb.literal(1), cb.nullLiteral(Integer.class))); + assertEquals(0, em.createQuery(positive).getSingleResult().intValue()); + + // Junction-barriered negation, mirroring the adapter's negate() helper. + CriteriaQuery negated = cb.createQuery(Long.class); + negated.select(cb.count(negated.from(ResourceEntity.class))); + negated.where(cb.not(cb.and( + cb.equal(cb.literal(1), cb.nullLiteral(Integer.class))))); + assertEquals(0, em.createQuery(negated).getSingleResult().intValue()); + } finally { + em.close(); + } + }); + } + + @Test + void notExistsWithNullElementExcludesRow() { + // Single NULL-name element: exists = error (no true, one error) → deny. + // The leak: EXISTS(name = 'public') is FALSE for the NULL element, and + // NOT(FALSE) = TRUE would include the row. + ResourceEntity r = new ResourceEntity("null-elem-exists-1"); + r.addTag("ne1", null); + withResource(r, () -> { + assertEquals(0, runCount(existsPublic())); + assertEquals(0, runCount(exprOp("not", existsPublic()))); + }); + } + + @Test + void existsAbsorbsErrorWhenAnotherElementIsTrue() { + // Positive control: CEL exists absorbs errors through a true witness, so the + // row IS included even though a sibling element is NULL. + ResourceEntity r = new ResourceEntity("null-elem-exists-2"); + r.addTag("ne2a", null); + r.addTag("ne2b", "public"); + withResource(r, () -> { + assertEquals(1, runCount(existsPublic())); + assertEquals(0, runCount(exprOp("not", existsPublic()))); + }); + } + + @Test + void allWithNullElementAndNoFalseExcludesRow() { + // No false element, one NULL element: all = error → deny under BOTH polarities. + // The leak: NOT EXISTS(NOT(name != 'x')) is TRUE (the UNKNOWN body never matches). + ResourceEntity lone = new ResourceEntity("null-elem-all-1"); + lone.addTag("na1", null); + withResource(lone, () -> { + assertEquals(0, runCount(allNotX())); + assertEquals(0, runCount(exprOp("not", allNotX()))); + }); + + // Mixed collection: a determined-true sibling must not mask the unknown element. + ResourceEntity mixed = new ResourceEntity("null-elem-all-2"); + mixed.addTag("na2a", null); + mixed.addTag("na2b", "ok"); + withResource(mixed, () -> { + assertEquals(0, runCount(allNotX())); + assertEquals(0, runCount(exprOp("not", allNotX()))); + }); + } + + @Test + void allFalseElementDominatesEvenWithNullElement() { + // CEL all absorbs errors through a false witness: all = false (not error), so + // NOT(all) must still include the row. + ResourceEntity r = new ResourceEntity("null-elem-all-3"); + r.addTag("na3a", "x"); + r.addTag("na3b", null); + withResource(r, () -> { + assertEquals(0, runCount(allNotX())); + assertEquals(1, runCount(exprOp("not", allNotX()))); + }); + } + + @Test + void existsOneWithNullElementExcludesRow() { + // exists_one has NO error absorption: one true + one NULL element still errors. + ResourceEntity oneTrueOneNull = new ResourceEntity("null-elem-one-1"); + oneTrueOneNull.addTag("no1a", "public"); + oneTrueOneNull.addTag("no1b", null); + withResource(oneTrueOneNull, () -> { + assertEquals(0, runCount(existsOnePublic())); + assertEquals(0, runCount(exprOp("not", existsOnePublic()))); + }); + + // Zero true + one NULL element: COUNT(...) = 1 is FALSE, and NOT would leak. + ResourceEntity onlyNull = new ResourceEntity("null-elem-one-2"); + onlyNull.addTag("no2a", null); + withResource(onlyNull, () -> { + assertEquals(0, runCount(existsOnePublic())); + assertEquals(0, runCount(exprOp("not", existsOnePublic()))); + }); + + // Control: exactly one true, no NULLs — unchanged behaviour. + ResourceEntity clean = new ResourceEntity("null-elem-one-3"); + clean.addTag("no3a", "public"); + clean.addTag("no3b", "other"); + withResource(clean, () -> { + assertEquals(1, runCount(existsOnePublic())); + assertEquals(0, runCount(exprOp("not", existsOnePublic()))); + }); + } + + @Test + void filterAndExceptFollowExistsFamilySemantics() { + Operand filterPublic = exprOp("filter", var("request.resource.attr.tags"), + lambda("t", exprOp("eq", var("t.name"), sval("public")))); + Operand exceptPublic = exprOp("except", var("request.resource.attr.tags"), + lambda("t", exprOp("eq", var("t.name"), sval("public")))); + + ResourceEntity r = new ResourceEntity("null-elem-fe-1"); + r.addTag("fe1", null); + withResource(r, () -> { + assertEquals(0, runCount(filterPublic)); + assertEquals(0, runCount(exprOp("not", filterPublic))); + assertEquals(0, runCount(exceptPublic)); + assertEquals(0, runCount(exprOp("not", exceptPublic))); + }); + } + + @Test + void mapIntersectionWithNullProjectionExcludesRow() { + // CEL map() has no error absorption: a NULL projected column errors the whole + // hasIntersection even when another element would intersect. + Operand mapNames = exprOp("hasIntersection", + exprOp("map", var("request.resource.attr.tags"), + lambda("t", var("t.name"))), + listOp("public")); + + ResourceEntity withNull = new ResourceEntity("null-elem-map-1"); + withNull.addTag("nm1a", "public"); + withNull.addTag("nm1b", null); + withResource(withNull, () -> { + assertEquals(0, runCount(mapNames)); + assertEquals(0, runCount(exprOp("not", mapNames))); + }); + + // Control: no NULL projections — the plain intersection still matches. + ResourceEntity clean = new ResourceEntity("null-elem-map-2"); + clean.addTag("nm2a", "public"); + withResource(clean, () -> { + assertEquals(1, runCount(mapNames)); + assertEquals(0, runCount(exprOp("not", mapNames))); + }); + } + } + @Nested class HierarchyOperators { @@ -1428,6 +1749,65 @@ void constantVersusConstantComparisonsFold() { withResource(boolFalse, () -> assertEquals(0, runCount(allConstBranches))); } + @Test + void negatedTernaryWithNullConditionExcludesRow() { + // !((aOptionalString != "x" ? aNumber : 0.0) > 1.0) — a NULL condition column is + // a CEL evaluation error (deny), so the SQL must be UNKNOWN under BOTH polarities. + Operand comparison = exprOp("gt", + exprOp("if", + exprOp("ne", var("request.resource.attr.aOptionalString"), sval("x")), + var("request.resource.attr.aNumber"), + nval(0.0)), + nval(1.0)); + + // Both branch comparisons false: (NULL AND FALSE) OR (NULL AND FALSE) must not + // collapse to FALSE — NOT(FALSE) = TRUE would leak the row. + ResourceEntity bothBranchesFalse = new ResourceEntity("ternary-nullcond-1"); + bothBranchesFalse.setaOptionalString(null); + bothBranchesFalse.setaNumber(0); + withResource(bothBranchesFalse, () -> { + assertEquals(0, runCount(comparison)); + assertEquals(0, runCount(exprOp("not", comparison))); + }); + + // Then-branch true: still UNKNOWN (the PDP denies), excluded either way. + ResourceEntity thenBranchTrue = new ResourceEntity("ternary-nullcond-2"); + thenBranchTrue.setaOptionalString(null); + thenBranchTrue.setaNumber(5); + withResource(thenBranchTrue, () -> { + assertEquals(0, runCount(comparison)); + assertEquals(0, runCount(exprOp("not", comparison))); + }); + + // Known-condition control: the UNKNOWN arm must vanish for non-NULL conditions. + ResourceEntity knownCondition = new ResourceEntity("ternary-nullcond-3"); + knownCondition.setaOptionalString("y"); + knownCondition.setaNumber(5); + withResource(knownCondition, () -> { + assertEquals(1, runCount(comparison)); + assertEquals(0, runCount(exprOp("not", comparison))); + }); + } + + @Test + void negatedBareTernaryWithNullConditionExcludesRow() { + // aOptionalString != "x" ? aNumber > 1 : aBool — bare boolean-position ternary + // with a NULL condition column: same UNKNOWN-not-FALSE contract as above. + Operand plan = exprOp("if", + exprOp("ne", var("request.resource.attr.aOptionalString"), sval("x")), + exprOp("gt", var("request.resource.attr.aNumber"), nval(1)), + var("request.resource.attr.aBool")); + + ResourceEntity nullCondition = new ResourceEntity("ternary-barenull-1"); + nullCondition.setaOptionalString(null); + nullCondition.setaNumber(0); + nullCondition.setaBool(false); + withResource(nullCondition, () -> { + assertEquals(0, runCount(plan)); + assertEquals(0, runCount(exprOp("not", plan))); + }); + } + @Test void ternaryWithWrongOperandCountThrows() { // if() with 2 operands inside a comparison — malformed plan, not a silent drop. @@ -1492,6 +1872,193 @@ void outerAttributeInsideExistsLambda() { } } + /** + * Structural join-anchoring defects: + * + *

W1 — a dotted relation CHAIN ({@code categories.subCategories}) must join through + * every intermediate hop. Resolving only the tail Relation and joining its attribute off + * the root either fails at query-build time (the root has no such attribute) or — worse — + * silently joins a same-named collection on the wrong entity. Chain semantics are the + * FLATTENED union of tail elements across all intermediate hops, which is exactly what a + * correlated join chain expresses for exists/in/hasIntersection and a JOIN-through COUNT + * expresses for size(). + * + *

W2 — a subquery for a relation referenced inside a lambda body must correlate the + * From that OWNS the relation attribute. {@code R.attr.tags} inside a + * {@code categories.exists(c, ...)} lambda resolves through the outer scope against the + * ROOT entity; anchoring the tags join to the lambda's category join instead is a wrong + * From — build-time failure or a silent wrong join if the element entity had a same-named + * collection. + */ + @Nested + class MultiHopRelationChains { + + private static final String CHAIN = "request.resource.attr.categories.subCategories"; + + private final Map chainMapper = Map.ofEntries( + Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), + Map.entry("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "id", AttributeMapping.field("id"), + "name", AttributeMapping.field("name")))), + Map.entry("request.resource.attr.categories", AttributeMapping.relation("categories", Map.of( + "name", AttributeMapping.field("name"), + "subCategories", AttributeMapping.relation("subCategories", "name", Map.of( + "name", AttributeMapping.field("name"))))))); + + private int runChainCount(Operand condition) { + return runCount(condition, chainMapper, Map.of()); + } + + /** + * Persist a resource plus its (non-cascaded) category/sub-category graph, run + * {@code body}, then delete everything again — the shared in-memory schema must stay + * empty for the other tests. + */ + private void withCategoryGraph(ResourceEntity resource, + java.util.List categories, + java.util.List subCategories, + Runnable body) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + subCategories.forEach(em::persist); + categories.forEach(em::persist); + em.persist(resource); + em.getTransaction().commit(); + em.close(); + try { + body.run(); + } finally { + EntityManager cleanup = emf.createEntityManager(); + cleanup.getTransaction().begin(); + ResourceEntity managed = cleanup.find(ResourceEntity.class, resource.getId()); + if (managed != null) { + cleanup.remove(managed); + } + for (dev.cerbos.queryplan.springdata.testmodel.CategoryEntity c : categories) { + dev.cerbos.queryplan.springdata.testmodel.CategoryEntity mc = + cleanup.find(dev.cerbos.queryplan.springdata.testmodel.CategoryEntity.class, c.getId()); + if (mc != null) { + cleanup.remove(mc); + } + } + for (dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity s : subCategories) { + dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity ms = + cleanup.find(dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity.class, s.getId()); + if (ms != null) { + cleanup.remove(ms); + } + } + cleanup.getTransaction().commit(); + cleanup.close(); + } + } + + @Test + void existsOverTwoHopChainJoinsThroughIntermediateHop() { + var fin = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-e1", "finance"); + var biz = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-e1", "business"); + biz.setSubCategories(java.util.List.of(fin)); + ResourceEntity r = new ResourceEntity("chain-r-e1"); + r.setCategories(java.util.List.of(biz)); + + withCategoryGraph(r, java.util.List.of(biz), java.util.List.of(fin), () -> { + Operand matching = exprOp("exists", var(CHAIN), + lambda("s", exprOp("eq", var("s.name"), sval("finance")))); + assertEquals(1, runChainCount(matching)); + + Operand nonMatching = exprOp("exists", var(CHAIN), + lambda("s", exprOp("eq", var("s.name"), sval("nope")))); + assertEquals(0, runChainCount(nonMatching)); + }); + } + + @Test + void inOverTwoHopChainJoinsThroughIntermediateHop() { + var fin = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-i1", "finance"); + var biz = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-i1", "business"); + biz.setSubCategories(java.util.List.of(fin)); + ResourceEntity r = new ResourceEntity("chain-r-i1"); + r.setCategories(java.util.List.of(biz)); + + withCategoryGraph(r, java.util.List.of(biz), java.util.List.of(fin), () -> { + // "finance" in R.attr.categories.subCategories — value-first, as the planner + // preserves source order; membership tests the tail's defaultMemberField (name). + assertEquals(1, runChainCount(exprOp("in", sval("finance"), var(CHAIN)))); + assertEquals(0, runChainCount(exprOp("in", sval("nope"), var(CHAIN)))); + }); + } + + @Test + void hasIntersectionOverTwoHopChainJoinsThroughIntermediateHop() { + var fin = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-h1", "finance"); + var biz = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-h1", "business"); + biz.setSubCategories(java.util.List.of(fin)); + ResourceEntity r = new ResourceEntity("chain-r-h1"); + r.setCategories(java.util.List.of(biz)); + + withCategoryGraph(r, java.util.List.of(biz), java.util.List.of(fin), () -> { + assertEquals(1, runChainCount( + exprOp("hasIntersection", var(CHAIN), listOp("finance", "zz")))); + assertEquals(0, runChainCount( + exprOp("hasIntersection", var(CHAIN), listOp("zz")))); + }); + } + + @Test + void sizeOverTwoHopChainCountsFlattenedElements() { + // Two categories with one sub-category each: the FLATTENED chain count is 2 — a + // tail join anchored to the wrong parent could never produce it. + var s1 = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-s1", "finance"); + var s2 = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-s2", "tech"); + var c1 = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-s1", "business"); + var c2 = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-s2", "development"); + c1.setSubCategories(java.util.List.of(s1)); + c2.setSubCategories(java.util.List.of(s2)); + ResourceEntity r = new ResourceEntity("chain-r-s1"); + r.setCategories(java.util.List.of(c1, c2)); + + withCategoryGraph(r, java.util.List.of(c1, c2), java.util.List.of(s1, s2), () -> { + // Non-empty shortcut (EXISTS through the chain). + assertEquals(1, runChainCount( + exprOp("gt", exprOp("size", var(CHAIN)), nval(0)))); + // Arbitrary-N JOIN-through COUNT: 2 flattened elements. + assertEquals(1, runChainCount( + exprOp("ge", exprOp("size", var(CHAIN)), nval(2)))); + assertEquals(0, runChainCount( + exprOp("gt", exprOp("size", var(CHAIN)), nval(2)))); + }); + } + + @Test + void rootRelationSubqueryInsideLambdaAnchorsToOwningEntity() { + // W2: R.attr.categories.exists(c, c.name == "business" && R.attr.tags.exists(u, ...)) + // — the inner tags subquery must correlate the ROOT entity (owner of "tags"), not + // the category join the lambda scope is rooted at. + var fin = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-w1", "finance"); + var biz = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-w1", "business"); + biz.setSubCategories(java.util.List.of(fin)); + ResourceEntity r = new ResourceEntity("chain-r-w1"); + r.setCategories(java.util.List.of(biz)); + r.addTag("chain-tag-w1", "public"); + + withCategoryGraph(r, java.util.List.of(biz), java.util.List.of(fin), () -> { + Operand matching = exprOp("exists", var("request.resource.attr.categories"), + lambda("c", exprOp("and", + exprOp("eq", var("c.name"), sval("business")), + exprOp("exists", var("request.resource.attr.tags"), + lambda("u", exprOp("eq", var("u.name"), sval("public"))))))); + assertEquals(1, runChainCount(matching)); + + Operand nonMatching = exprOp("exists", var("request.resource.attr.categories"), + lambda("c", exprOp("and", + exprOp("eq", var("c.name"), sval("business")), + exprOp("exists", var("request.resource.attr.tags"), + lambda("u", exprOp("eq", var("u.name"), sval("private"))))))); + assertEquals(0, runChainCount(nonMatching)); + }); + } + } + // -- Malformed / hostile operand shapes -- @Test @@ -1622,6 +2189,123 @@ void emptyNeedleColumnMatchesLikeCel() { } } + // -- Constant-receiver string matches: `"a,b".contains(R.attr.x)` -- + // CEL string-match methods are receiver-sensitive and the planner preserves policy source + // order, so the constant RECEIVER arrives FIRST: contains(value, variable). The constant is + // the haystack and the COLUMN is the needle — operand-order normalization must not swap + // them (that silently inverts the match), and the column needle's LIKE metacharacters must + // be escaped dynamically. + + @Nested + class ConstantReceiverStringMatch { + + private ResourceEntity row(String id, String aString) { + ResourceEntity r = new ResourceEntity(id); + r.setaString(aString); + return r; + } + + private Operand plan(String op, String constant) { + // Receiver (constant) first — exactly as the planner emits it. + return exprOp(op, sval(constant), var("request.resource.attr.aString")); + } + + @Test + void constantReceiverContains() { + // "role1,role2".contains(aString): aString="role1" IS contained → match. + // The inverted translation (aString LIKE '%role1,role2%') would return 0. + withResource(row("cr-1", "role1"), () -> { + assertEquals(1, runCount(plan("contains", "role1,role2"))); + // Control: the column-receiver form keeps its meaning. + assertEquals(0, runCount(exprOp("contains", + var("request.resource.attr.aString"), sval("role1,role2")))); + }); + withResource(row("cr-2", "admin"), () -> + assertEquals(0, runCount(plan("contains", "role1,role2")))); + } + + @Test + void constantReceiverStartsWith() { + // "one_two,three".startsWith(aString): aString="one_two" is a prefix → match. + withResource(row("cr-3", "one_two"), () -> + assertEquals(1, runCount(plan("startsWith", "one_two,three")))); + withResource(row("cr-4", "three"), () -> + assertEquals(0, runCount(plan("startsWith", "one_two,three")))); + } + + @Test + void constantReceiverEndsWith() { + withResource(row("cr-5", "one_two"), () -> + assertEquals(1, runCount(plan("endsWith", "three,one_two")))); + withResource(row("cr-6", "three"), () -> + assertEquals(0, runCount(plan("endsWith", "three,one_two")))); + } + + @Test + void columnNeedleMetacharactersAreEscaped() { + // Column holds "a_b"; the constant "aXb-list" does NOT literally contain it, but + // an UNESCAPED needle pattern ('%a_b%') would match the 'X'. Same for the + // startsWith/endsWith shapes. + withResource(row("cr-7", "a_b"), () -> { + assertEquals(0, runCount(plan("contains", "aXb-list"))); + assertEquals(0, runCount(plan("startsWith", "aXb-list"))); + assertEquals(0, runCount(plan("endsWith", "list-aXb"))); + }); + // Literal metacharacter matches only work when the escape is correct. + withResource(row("cr-8", "a_b"), () -> { + assertEquals(1, runCount(plan("contains", "xa_by"))); + assertEquals(1, runCount(plan("startsWith", "a_b-tail"))); + assertEquals(1, runCount(plan("endsWith", "head-a_b"))); + }); + } + + @Test + void nullColumnNeedleExcludesRow() { + // A NULL column is a missing attribute → CEL error → deny for all three ops. + withResource(row("cr-9", null), () -> { + assertEquals(0, runCount(plan("contains", "anything"))); + assertEquals(0, runCount(plan("startsWith", "anything"))); + assertEquals(0, runCount(plan("endsWith", "anything"))); + }); + } + + @Test + void emptyColumnNeedleMatchesLikeCel() { + // CEL: "x".contains("") / startsWith("") / endsWith("") are all true. + withResource(row("cr-10", ""), () -> { + assertEquals(1, runCount(plan("contains", "x"))); + assertEquals(1, runCount(plan("startsWith", "x"))); + assertEquals(1, runCount(plan("endsWith", "x"))); + }); + } + + @Test + void addFoldedConstantReceiver() { + // ("role1," + "role2").contains(aString) — if the planner ever ships the concat + // unfolded, the receiver arrives as add(value, value) and must fold into the same + // constant-haystack translation, not the inverted column-haystack one. + Operand cond = exprOp("contains", + exprOp("add", sval("role1,"), sval("role2")), + var("request.resource.attr.aString")); + withResource(row("cr-11", "role1"), () -> assertEquals(1, runCount(cond))); + withResource(row("cr-12", "admin"), () -> assertEquals(0, runCount(cond))); + } + } + + // -- Leaf operand-count guard: extra operands must fail loudly, not drop silently -- + + @Test + void leafWithExtraOperandThrows() { + // A 3-operand eq previously kept the field-to-field comparison and silently DROPPED + // the value operand. Malformed plans must throw instead. + assertConditionThrows( + exprOp("eq", + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy"), + sval("x")), + "eq", "2 operands"); + } + // -- SPIKE 2: arithmetic (add/sub/mult/div) as a comparison operand -- // Cerbos attribute values are ALWAYS CEL doubles (protobuf Value numbers), so the only // arithmetic that can evaluate at check time is double-typed — verified against a live @@ -1735,6 +2419,46 @@ void nestedArithmetic() { }); } + @Test + void divByZeroColumnDivisorDoesNotAbortQuery() { + // gt(div(aNumber, aNumber), 0.5): a zero-valued row makes the divisor 0. SQL + // division by zero would abort the WHOLE query; CEL 0.0/0.0 is NaN, whose + // comparisons are all false → deny. The divisor is guarded with NULLIF so the + // zero-divisor row becomes UNKNOWN → excluded, and the query survives. + Operand cond = exprOp("gt", + exprOp("div", numVar(), numVar()), + nval(0.5)); + ResourceEntity nonZero = new ResourceEntity("div-zero-1"); + nonZero.setaNumber(5); + ResourceEntity zero = new ResourceEntity("div-zero-2"); + zero.setaNumber(0); + withResource(nonZero, () -> withResource(zero, () -> + // 5/5 = 1.0 > 0.5 → only the non-zero-divisor row matches. + assertEquals(1, runCount(cond)))); + } + + @Test + void fractionalMultiplicationComparesInIeeeDoubleSpace() { + // IEEE doubles: 3 * 0.1 = 0.30000000000000004 != 0.3, so CEL (and the PDP) + // exclude the row. Decimal-exact DB arithmetic would wrongly include it. + ResourceEntity r = new ResourceEntity("frac-mult-1"); + r.setaNumber(3); + withResource(r, () -> assertEquals(0, runCount(exprOp("eq", + exprOp("mult", numVar(), nval(0.1)), + nval(0.3))))); + } + + @Test + void overrideAppliesToArithmeticComparison() { + // OperatorFunction contract: overrides win on EVERY scalar path. The arithmetic + // expression is passed as the field argument; the plan constant as the value. + Operand cond = exprOp("gt", + exprOp("add", numVar(), nval(1.0)), + nval(2.0)); + assertThrows(OverrideInvoked.class, + () -> runCount(cond, Map.of("gt", THROWING_OVERRIDE))); + } + @Test void modStillThrows() { // CEL `%` has no double overload and attribute values are always doubles, so a diff --git a/spring-data/src/test/resources/adversarial-policy.yaml b/spring-data/src/test/resources/adversarial-policy.yaml index e5c30c0e..6af80da9 100644 --- a/spring-data/src/test/resources/adversarial-policy.yaml +++ b/spring-data/src/test/resources/adversarial-policy.yaml @@ -367,3 +367,327 @@ resourcePolicy: condition: match: expr: 'R.attr.aNumber + 1.0 > R.attr.aNumber * 2.0' + + # ==================== clean-room probes (coverage gap hunt) ==================== + + # -- GROUP A: compositions claimed-supported but unproven e2e -- + + # A1: ternary inside an exists() lambda body. + - actions: ["p-ternary-in-exists"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.tags.exists(t, (R.attr.aBool ? t.name : "zz") == "public")' + + # A2: arithmetic inside a lambda body (over the outer numeric attr). + - actions: ["p-arith-in-lambda"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.tags.exists(t, t.name == "public" && R.attr.aNumber + 1.0 > 2.0)' + + # A3: field-to-field where BOTH fields are lambda-element columns (b4 is the witness). + - actions: ["p-lambda-inner-f2f"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.tags.exists(t, t.id == t.name)' + + # A4: field-to-field LIKE inside a lambda, needle is an OUTER attr column + # (a8 has aString == "" so every tag matches contains("")). + - actions: ["p-lambda-f2f-like"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.tags.exists(t, t.name.contains(R.attr.aString))' + + # A5: size() of a NESTED relation path (relation reached through another relation). + - actions: ["p-size-nested"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.categories.exists(c, size(c.subCategories) == 1)' + + # A6a: ternary whose BOTH branches are themselves ternaries. + - actions: ["p-ternary-of-ternaries"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '(R.attr.aBool ? (R.attr.aString == "" ? 0.0 : R.attr.aNumber) : (R.attr.aNumber < 0.0 ? -1.0 : 5.0)) > 1.0' + + # A6b: ternaries on BOTH sides of the comparison. + - actions: ["p-ternary-vs-ternary"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '(R.attr.aBool ? R.attr.aNumber : 0.0) > (R.attr.aString == "" ? 1.0 : 2.0)' + + # A6c: ternary under all(). + - actions: ["p-ternary-under-all"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.tags.all(t, (R.attr.aBool ? t.name : "zz") != "private")' + + # A7: hasIntersection over a map(...) projection with unicode + LIKE metachars in the list. + - actions: ["p-hasintersection-map"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'hasIntersection(R.attr.tags.map(t, t.name), ["public", "héllo🚀", "100%_x"])' + + # A8: deep and/or/not nesting (5+ levels) mixing arith, LIKE, exists, f2f. + - actions: ["p-deep-nest"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!((R.attr.aBool && (R.attr.aNumber + 1.0 > 2.0 || R.attr.aString.startsWith("100%"))) || (!R.attr.aBool && R.attr.tags.exists(t, t.name == "public" || t.name == R.attr.aString)))' + + # A9a: single-element in against a NULLABLE column. + - actions: ["p-in-null-single"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aOptionalString in ["x"]' + + # A9b: multi-element in against a NULLABLE column. + - actions: ["p-in-null-multi"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aOptionalString in ["x", "one_two"]' + + # A10: startsWith with a constant-folded concat needle. + - actions: ["p-startswith-concat"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aString.startsWith("100" + "%")' + + # -- GROUP B: suspected-unsupported shapes; record throw vs silent-wrong -- + + # B11: timestamp comparison over an ISO-date string column (createdBy is seeded with ISO dates). + - actions: ["p-timestamp"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'timestamp(R.attr.createdBy) < timestamp("2025-01-01T00:00:00Z")' + + # B12: has() macro over an optional attribute. + - actions: ["p-has"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'has(R.attr.aOptionalString)' + + # B13: map/struct member access (obj.inner mirrors aString on the check side). + - actions: ["p-struct"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.obj.inner == "one"' + + # B14: matches() regex. + - actions: ["p-matches"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aString.matches("^h")' + + # B15: list indexing. + - actions: ["p-index"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.tags[0].name == "public"' + + # B16: negation of a NULL-condition ternary (three-valued logic under NOT). + - actions: ["p-not-ternary-null"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!((R.attr.aOptionalString != "x" ? R.attr.aNumber : 0.0) > 1.0)' + + # B17: negated exists over rows with ZERO tags (a2/b1/b2/b3). + - actions: ["p-not-exists-empty"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!R.attr.tags.exists(t, t.name == "public")' + + # B18: non-representable fraction: 3 * 0.1 != 0.3 in IEEE double. + - actions: ["p-double-frac"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aNumber * 0.1 == 0.3' + + # ==================== NULL element columns under collection macros ==================== + # b5 has ONLY a NULL-name tag; b6 mixes a NULL-name tag with a "public" one. A missing + # element attribute is a CEL evaluation error: exists/all absorb it only through a + # true/false witness, exists_one never does. The SQL translation must keep these rows + # UNKNOWN (excluded under both polarities), never FALSE — NOT(FALSE) leaks. + + # exists_one errors on ANY erroring element — even alongside a true one (b6). + - actions: ["n-not-exists-one-null"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!R.attr.tags.exists_one(t, t.name == "public")' + + # all with a determined-true sibling and one NULL element (b6): no false witness, so the + # error surfaces — the unknown-element detection must work on MIXED collections. + - actions: ["n-all-mixed-null"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.tags.all(t, t.name != "x")' + + # NOT over an erroring all(): !error is still an error → deny. + - actions: ["n-not-all-null"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!R.attr.tags.all(t, t.name == "public")' + + # Absorption control: b6 has a FALSE witness for this body ("public" != "public"), so + # all = false (not error) and the negation must still ALLOW despite the NULL sibling. + - actions: ["n-not-all-absorb"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '!R.attr.tags.all(t, t.name != "public")' + + # ==================== constant-receiver string matches ==================== + # The CONSTANT is the receiver (haystack) and the COLUMN the needle: the planner preserves + # source order, so these arrive as contains(value, variable) and must NOT be operand-order + # normalized (swapping silently inverts haystack and needle). Constants are chosen so the + # metacharacter seeds discriminate needle escaping: a2 "100%_done" (unescaped '%…_…' would + # falsely match "…100Xdone…"), a4 "xa_by" (unescaped '_' would match the 'X' in "xaXby…"), + # a7 "tail\" (a mis-escaped '\' turns the pattern into a literal '%'). + + # True for a1 ("one" ⊂ "…Xdone…"), a7 ("tail\" ⊂ "…-tail\one…"), a8 (""); a2 is the + # escaping discriminator. + - actions: ["cr-contains"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '"s100Xdone-tail\\one-end".contains(R.attr.aString)' + + # True for a5 ("xaXby" prefix) and a8 (""); a4 "xa_by" is the escaping discriminator. + - actions: ["cr-startswith"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '"xaXby-tail".startsWith(R.attr.aString)' + + # True for a5 ("xaXby" suffix) and a8 (""); a4 "xa_by" is the escaping discriminator. + - actions: ["cr-endswith"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '"prefix-xaXby".endsWith(R.attr.aString)' + + # Concat RECEIVER: whether the planner ships it folded or as add(value, value), the + # adapter must treat the fold as the haystack, never as a column-receiver LIKE. + - actions: ["cr-startswith-concat"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '("xaX" + "by-tail").startsWith(R.attr.aString)' + + # ==================== arithmetic edge probes ==================== + + # Column divisor with a zero row (a7 aNumber=0): CEL 0.0/0.0 is NaN → all comparisons + # false → deny; the SQL must not abort on division by zero (NULLIF guard → UNKNOWN → + # excluded, which matches the NaN denial exactly). + - actions: ["cr-div-zero"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.aNumber / R.attr.aNumber > 0.5' + + # ==================== multi-hop relation chains (W1) ==================== + # Direct dotted-chain syntax. mainCategory is a SINGLE nested object on the check side + # (each seed holds at most one category) so CEL evaluates the chain naturally, while the + # adapter maps the same path through TWO collection hops (categories JOIN subCategories): + # the translated subquery must join through the intermediate hop, never off the root. + # Rows without the attribute are a CEL missing-attr error (deny) on the check side and an + # empty join chain (excluded) on the SQL side. + + - actions: ["w1-exists-chain"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.mainCategory.subCategories.exists(s, s.name == "finance")' + + # size() over the chain = JOIN-through COUNT of the flattened tail elements. + - actions: ["w1-size-chain"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'size(R.attr.mainCategory.subCategories) > 0' + + # Plain membership over the chained string list (adapter: defaultMemberField name). + - actions: ["w1-in-chain"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: '"finance" in R.attr.mainCategory.subNames' + + # ==================== root relation inside a lambda body (W2) ==================== + # The inner R.attr.tags.exists(...) is planned INSIDE the categories lambda; its subquery + # must correlate the ROOT entity (owner of "tags"), not the category join the lambda scope + # is rooted at — CategoryEntity has no tags collection, so a wrongly anchored join fails at + # query-build time (and would be silently wrong if a same-named collection existed). + - actions: ["w2-outer-relation"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'R.attr.categories.exists(c, size(c.subCategories) == 1 && R.attr.tags.exists(t, t.name == "public"))' + + # ==================== fractional size() thresholds ==================== + # COUNT is integral: >= 1.5 must round UP to >= 2 (truncation to >= 1 over-includes). + # Only the ORDERING operators are probeable end to end — CEL rejects ==/!= between int + # and double (no matching overload), so the planner can never emit a fractional eq/ne + # against size(); the defensive adapter collapse for those is unit-tested only. + - actions: ["cr-size-frac-ge"] + effect: EFFECT_ALLOW + roles: ["USER"] + condition: + match: + expr: 'size(R.attr.tags) >= 1.5' From 5be7a88f482121093d0820e8c79b4d9cd2f67ec6 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 20 Jul 2026 19:22:01 +0100 Subject: [PATCH 17/20] chore(spring-data): strip review-process comments, de-qualify imports Deslop pass over the last three commits: removed diff-narration and duplicated comments, dev-process section labels (SPIKE 1/2), and ~30 fully-qualified names where imports match file style. No behavior or assertion changes; truth-table and CEL-semantics Javadoc kept. Signed-off-by: Alex Olivier --- .../SpringDataQueryPlanAdapter.java | 6 +- .../AdversarialConformanceTest.java | 8 +- .../SpringDataQueryPlanAdapterTest.java | 86 ++++++++++--------- 3 files changed, 51 insertions(+), 49 deletions(-) diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 709c85c5..972529da 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -327,8 +327,7 @@ private Predicate handleBareTernary(List operands, Scope scope) { Operand thenBranch = operands.get(1); Operand elseBranch = operands.get(2); - // A constant boolean condition folds to a single branch — translate only that branch - // so an untranslatable dead branch cannot fail the whole plan. + // Constant boolean condition folds to a single branch — see tryTernaryComparison. if (condition.getNodeCase() == Operand.NodeCase.VALUE) { Boolean known = constantBooleanOrNull(condition); if (known == null) { @@ -387,8 +386,7 @@ private static Boolean constantBooleanOrNull(Operand o) { /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ private Predicate handleLeafOperator(String op, List operands, Scope scope) { // Every leaf operator is binary. Extra operands are a malformed plan and must fail - // loudly: the collector below would otherwise silently DROP one (a 3-operand eq - // kept the field-to-field comparison and ignored the value). + // loudly: the collector below would otherwise silently DROP one. if (operands.size() != 2) { throw new IllegalArgumentException( op + " requires exactly 2 operands, got " + operands.size()); diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java index a212a0ef..697423ad 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import org.slf4j.LoggerFactory; import org.springframework.data.jpa.domain.Specification; @@ -40,6 +41,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -62,7 +64,7 @@ class AdversarialConformanceTest { Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), Map.entry("request.resource.attr.aNumber", AttributeMapping.field("aNumber")), Map.entry("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")), - // probe additions: ISO-date string column + flattened struct member + // ISO-date string column + flattened struct member for the p-* probes Map.entry("request.resource.attr.createdBy", AttributeMapping.field("createdBy")), Map.entry("request.resource.attr.obj.inner", AttributeMapping.field("aString")), Map.entry("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( @@ -359,13 +361,13 @@ void adapterMatchesCheckOracle(String action) { * silently-wrong filter). Messages pinned so a regression to silent acceptance is caught. */ @ParameterizedTest(name = "{0}") - @org.junit.jupiter.params.provider.CsvSource({ + @CsvSource({ "p-timestamp, Unexpected timestamp() expression in leaf operand of lt", "p-matches, Unsupported operator: matches", "p-index, Unexpected get-field() expression in leaf operand of eq", }) void unsupportedShapesThrow(String action, String expectedMessage) { - IllegalArgumentException ex = org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> adapterFilteredIds(action)); assertEquals(expectedMessage, ex.getMessage()); } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index 629050ef..90cbe05e 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -7,7 +7,9 @@ import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression; import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression.Operand; import dev.cerbos.api.v1.response.Response.PlanResourcesResponse; +import dev.cerbos.queryplan.springdata.testmodel.CategoryEntity; import dev.cerbos.queryplan.springdata.testmodel.ResourceEntity; +import dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; @@ -23,6 +25,8 @@ import org.junit.jupiter.api.Test; import org.springframework.data.jpa.domain.Specification; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -376,7 +380,7 @@ class SizeCountComparisons { private ResourceEntity seeded() { ResourceEntity r = new ResourceEntity("size-seed-1"); - r.setOwnedBy(new java.util.ArrayList<>(java.util.List.of("user1", "user2"))); + r.setOwnedBy(new ArrayList<>(List.of("user1", "user2"))); r.addTag("tagX", "x"); return r; } @@ -432,7 +436,7 @@ class FractionalSizeThresholds { private ResourceEntity seeded() { ResourceEntity r = new ResourceEntity("size-frac-seed-1"); - r.setOwnedBy(new java.util.ArrayList<>(java.util.List.of("user1", "user2"))); + r.setOwnedBy(new ArrayList<>(List.of("user1", "user2"))); return r; } @@ -1360,7 +1364,7 @@ private ResourceEntity seeded() { r.setaBool(true); r.setaString("seededString"); r.setaNumber(5); - r.setOwnedBy(new java.util.ArrayList<>(java.util.List.of("user1"))); + r.setOwnedBy(new ArrayList<>(List.of("user1"))); r.addTag("tagX", "x"); return r; } @@ -1447,7 +1451,7 @@ void overrideIsConsultedUnderMirroredOperator() { } } - // -- CEL ternary (PR: ternary support): `if(cond, then, else)` is rewritten into pure + // -- CEL ternary: `if(cond, then, else)` is rewritten into pure // predicates — cmp(if(c,a,b), other) → (c AND cmp(a, other)) OR (NOT c AND cmp(b, other)). // Seeds real rows because an empty table cannot distinguish the branch predicates. @@ -1915,8 +1919,8 @@ private int runChainCount(Operand condition) { * empty for the other tests. */ private void withCategoryGraph(ResourceEntity resource, - java.util.List categories, - java.util.List subCategories, + List categories, + List subCategories, Runnable body) { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); @@ -1934,16 +1938,14 @@ private void withCategoryGraph(ResourceEntity resource, if (managed != null) { cleanup.remove(managed); } - for (dev.cerbos.queryplan.springdata.testmodel.CategoryEntity c : categories) { - dev.cerbos.queryplan.springdata.testmodel.CategoryEntity mc = - cleanup.find(dev.cerbos.queryplan.springdata.testmodel.CategoryEntity.class, c.getId()); + for (CategoryEntity c : categories) { + CategoryEntity mc = cleanup.find(CategoryEntity.class, c.getId()); if (mc != null) { cleanup.remove(mc); } } - for (dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity s : subCategories) { - dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity ms = - cleanup.find(dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity.class, s.getId()); + for (SubCategoryEntity s : subCategories) { + SubCategoryEntity ms = cleanup.find(SubCategoryEntity.class, s.getId()); if (ms != null) { cleanup.remove(ms); } @@ -1955,13 +1957,13 @@ private void withCategoryGraph(ResourceEntity resource, @Test void existsOverTwoHopChainJoinsThroughIntermediateHop() { - var fin = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-e1", "finance"); - var biz = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-e1", "business"); - biz.setSubCategories(java.util.List.of(fin)); + var fin = new SubCategoryEntity("chain-sub-e1", "finance"); + var biz = new CategoryEntity("chain-cat-e1", "business"); + biz.setSubCategories(List.of(fin)); ResourceEntity r = new ResourceEntity("chain-r-e1"); - r.setCategories(java.util.List.of(biz)); + r.setCategories(List.of(biz)); - withCategoryGraph(r, java.util.List.of(biz), java.util.List.of(fin), () -> { + withCategoryGraph(r, List.of(biz), List.of(fin), () -> { Operand matching = exprOp("exists", var(CHAIN), lambda("s", exprOp("eq", var("s.name"), sval("finance")))); assertEquals(1, runChainCount(matching)); @@ -1974,13 +1976,13 @@ void existsOverTwoHopChainJoinsThroughIntermediateHop() { @Test void inOverTwoHopChainJoinsThroughIntermediateHop() { - var fin = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-i1", "finance"); - var biz = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-i1", "business"); - biz.setSubCategories(java.util.List.of(fin)); + var fin = new SubCategoryEntity("chain-sub-i1", "finance"); + var biz = new CategoryEntity("chain-cat-i1", "business"); + biz.setSubCategories(List.of(fin)); ResourceEntity r = new ResourceEntity("chain-r-i1"); - r.setCategories(java.util.List.of(biz)); + r.setCategories(List.of(biz)); - withCategoryGraph(r, java.util.List.of(biz), java.util.List.of(fin), () -> { + withCategoryGraph(r, List.of(biz), List.of(fin), () -> { // "finance" in R.attr.categories.subCategories — value-first, as the planner // preserves source order; membership tests the tail's defaultMemberField (name). assertEquals(1, runChainCount(exprOp("in", sval("finance"), var(CHAIN)))); @@ -1990,13 +1992,13 @@ void inOverTwoHopChainJoinsThroughIntermediateHop() { @Test void hasIntersectionOverTwoHopChainJoinsThroughIntermediateHop() { - var fin = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-h1", "finance"); - var biz = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-h1", "business"); - biz.setSubCategories(java.util.List.of(fin)); + var fin = new SubCategoryEntity("chain-sub-h1", "finance"); + var biz = new CategoryEntity("chain-cat-h1", "business"); + biz.setSubCategories(List.of(fin)); ResourceEntity r = new ResourceEntity("chain-r-h1"); - r.setCategories(java.util.List.of(biz)); + r.setCategories(List.of(biz)); - withCategoryGraph(r, java.util.List.of(biz), java.util.List.of(fin), () -> { + withCategoryGraph(r, List.of(biz), List.of(fin), () -> { assertEquals(1, runChainCount( exprOp("hasIntersection", var(CHAIN), listOp("finance", "zz")))); assertEquals(0, runChainCount( @@ -2008,16 +2010,16 @@ void hasIntersectionOverTwoHopChainJoinsThroughIntermediateHop() { void sizeOverTwoHopChainCountsFlattenedElements() { // Two categories with one sub-category each: the FLATTENED chain count is 2 — a // tail join anchored to the wrong parent could never produce it. - var s1 = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-s1", "finance"); - var s2 = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-s2", "tech"); - var c1 = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-s1", "business"); - var c2 = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-s2", "development"); - c1.setSubCategories(java.util.List.of(s1)); - c2.setSubCategories(java.util.List.of(s2)); + var s1 = new SubCategoryEntity("chain-sub-s1", "finance"); + var s2 = new SubCategoryEntity("chain-sub-s2", "tech"); + var c1 = new CategoryEntity("chain-cat-s1", "business"); + var c2 = new CategoryEntity("chain-cat-s2", "development"); + c1.setSubCategories(List.of(s1)); + c2.setSubCategories(List.of(s2)); ResourceEntity r = new ResourceEntity("chain-r-s1"); - r.setCategories(java.util.List.of(c1, c2)); + r.setCategories(List.of(c1, c2)); - withCategoryGraph(r, java.util.List.of(c1, c2), java.util.List.of(s1, s2), () -> { + withCategoryGraph(r, List.of(c1, c2), List.of(s1, s2), () -> { // Non-empty shortcut (EXISTS through the chain). assertEquals(1, runChainCount( exprOp("gt", exprOp("size", var(CHAIN)), nval(0)))); @@ -2034,14 +2036,14 @@ void rootRelationSubqueryInsideLambdaAnchorsToOwningEntity() { // W2: R.attr.categories.exists(c, c.name == "business" && R.attr.tags.exists(u, ...)) // — the inner tags subquery must correlate the ROOT entity (owner of "tags"), not // the category join the lambda scope is rooted at. - var fin = new dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity("chain-sub-w1", "finance"); - var biz = new dev.cerbos.queryplan.springdata.testmodel.CategoryEntity("chain-cat-w1", "business"); - biz.setSubCategories(java.util.List.of(fin)); + var fin = new SubCategoryEntity("chain-sub-w1", "finance"); + var biz = new CategoryEntity("chain-cat-w1", "business"); + biz.setSubCategories(List.of(fin)); ResourceEntity r = new ResourceEntity("chain-r-w1"); - r.setCategories(java.util.List.of(biz)); + r.setCategories(List.of(biz)); r.addTag("chain-tag-w1", "public"); - withCategoryGraph(r, java.util.List.of(biz), java.util.List.of(fin), () -> { + withCategoryGraph(r, List.of(biz), List.of(fin), () -> { Operand matching = exprOp("exists", var("request.resource.attr.categories"), lambda("c", exprOp("and", exprOp("eq", var("c.name"), sval("business")), @@ -2099,7 +2101,7 @@ void operatorOverrideIsUsed() { assertEquals(0, runCount(cond, overrides)); } - // -- SPIKE 1: field-to-field contains/startsWith/endsWith -- + // -- Field-to-field contains/startsWith/endsWith -- // The needle is a COLUMN, so LIKE metacharacters it holds must be escaped dynamically // (nested REPLACE) before being wrapped in wildcards. CEL semantics: case-sensitive // literal substring; a NULL needle is a missing attribute → deny (row excluded). @@ -2306,7 +2308,7 @@ void leafWithExtraOperandThrows() { "eq", "2 operands"); } - // -- SPIKE 2: arithmetic (add/sub/mult/div) as a comparison operand -- + // -- Arithmetic (add/sub/mult/div) as a comparison operand -- // Cerbos attribute values are ALWAYS CEL doubles (protobuf Value numbers), so the only // arithmetic that can evaluate at check time is double-typed — verified against a live // PDP: `R.attr.n + 1 > 2` (int literal) is a no-overload error → deny, `+ 1.0` works, From 8903133f501684f03e30dabdf0cbd9c59725e58d Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Mon, 20 Jul 2026 19:52:21 +0100 Subject: [PATCH 18/20] refactor(spring-data): consolidate duplicated translator scaffolding Four-angle simplification pass (reuse/simplification/efficiency/altitude), behavior-preserving; all exception messages byte-identical: - shared translateTernary helper (arity, constant-cond fold, three-arm assembly) behind both ternary entry points - comparePredicate for expr-vs-expr comparison dispatch; parseLambda for the three lambda-shape validations; countSubquery for COUNT seeding; asList for the listify idiom; withOverride funnels all five override consultation sites so the policy is structural, not javadoc - Scope.rebase folded into rebaseAt (single rebase entry point) - size() probe no longer converts constants for non-size leaves (dead work on every ordinary comparison); hierarchy splitLiteral drops the per-call Pattern.compile; unreachable branch removed in field overlaps - TERNARY_COMPARISONS renamed COMPARISON_OPS; tri-state cost note corrected (up to five correlated subqueries for exists_one/size-filter) 344 tests green. Deferred (recorded in review): isSet removal (#261), add-ownership IR rewrite, Scope resolver sum type, test-fixture dedup. Signed-off-by: Alex Olivier --- .../springdata/HierarchyTranslator.java | 22 +- .../cerbos/queryplan/springdata/Scope.java | 32 +- .../SpringDataQueryPlanAdapter.java | 419 ++++++++++-------- .../springdata/SpringDataIntegrationTest.java | 5 +- .../SpringDataQueryPlanAdapterTest.java | 8 + 5 files changed, 267 insertions(+), 219 deletions(-) diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java index f156ac87..18ad535f 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java @@ -116,7 +116,7 @@ private Predicate handleFieldOverlaps(Hierarchy left, Hierarchy right) { // ...or a descendant of it. conditions.add(startsWithLiteral(field.path(), otherRaw + delimiter)); - return conditions.size() == 1 ? conditions.get(0) : cb.or(conditions.toArray(Predicate[]::new)); + return cb.or(conditions.toArray(Predicate[]::new)); } Predicate handleAncestorDescendant(List operands, Scope scope, boolean isAncestor) { @@ -300,8 +300,24 @@ private static List getStrictPrefixes(List segments, String deli return prefixes; } - /** Split on a literal delimiter (not a regex), keeping trailing empty segments. */ + /** + * Split on a literal delimiter (not a regex), keeping trailing empty segments — the + * {@code split(Pattern.quote(delimiter), -1)} semantics without compiling a Pattern per + * call ({@code \Q..\E} defeats String.split's single-char fast path). + */ private static List splitLiteral(String raw, String delimiter) { - return List.of(raw.split(Pattern.quote(delimiter), -1)); + if (delimiter.isEmpty()) { + // Zero-width delimiter: defer to the regex engine's empty-match semantics. + return List.of(raw.split(Pattern.quote(delimiter), -1)); + } + List parts = new ArrayList<>(); + int start = 0; + int idx; + while ((idx = raw.indexOf(delimiter, start)) >= 0) { + parts.add(raw.substring(start, idx)); + start = idx + delimiter.length(); + } + parts.add(raw.substring(start)); + return List.copyOf(parts); } } diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java index d88712bc..31102555 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java @@ -63,31 +63,23 @@ static Scope lambda(From from, AbstractQuery parentQuery, return new LambdaScope(from, parentQuery, relation, lambdaVar, outer); } - /** - * Re-root {@code scope} at the correlated copy of its {@code from} inside a subquery, so - * paths resolved through it become valid correlation references of that subquery. - */ - static Scope rebase(Scope scope, From correlated, AbstractQuery sub) { - if (scope instanceof RootScope rs) { - return new RootScope(correlated, sub, rs.mapper()); - } - LambdaScope ls = (LambdaScope) scope; - return new LambdaScope(correlated, sub, ls.relation(), ls.lambdaVar(), ls.outer()); - } - /** * Re-root the scope CHAIN for use inside a subquery that correlated {@code target}'s - * {@code from()}: the level identical to {@code target} is rebased at {@code correlated} - * (see {@link #rebase}); levels between {@code scope} and the target keep their Froms — - * paths through them stay legal as implicit correlation references, the same reliance - * {@link #rebase} already has on untouched {@code outer} links — but adopt {@code sub} as - * the query any deeper subqueries are built against. When {@code scope == target} this is - * exactly {@link #rebase}. Identity comparison is deliberate: the target is always a scope - * object returned by {@link #resolveRelation} on this same chain. + * {@code from()}: the level identical to {@code target} is re-rooted at {@code correlated}, + * so paths resolved through it become valid correlation references of that subquery; levels + * between {@code scope} and the target keep their Froms — paths through them stay legal as + * implicit correlation references, the same reliance the base case already places on + * untouched {@code outer} links — but adopt {@code sub} as the query any deeper subqueries + * are built against. Identity comparison is deliberate: the target is always a scope object + * returned by {@link #resolveRelation} on this same chain. */ static Scope rebaseAt(Scope scope, Scope target, From correlated, AbstractQuery sub) { if (scope == target) { - return rebase(scope, correlated, sub); + if (scope instanceof RootScope rs) { + return new RootScope(correlated, sub, rs.mapper()); + } + LambdaScope ls = (LambdaScope) scope; + return new LambdaScope(correlated, sub, ls.relation(), ls.lambdaVar(), ls.outer()); } if (scope instanceof LambdaScope ls && ls.outer() != null) { return new LambdaScope(ls.from(), sub, ls.relation(), ls.lambdaVar(), diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 972529da..52a86e9a 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -13,6 +13,8 @@ import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Subquery; +import com.google.protobuf.Value; + import java.util.List; import java.util.Map; import java.util.Objects; @@ -216,52 +218,78 @@ private static String mirror(String op) { // -- if (CEL ternary) -- - /** Binary comparison operators that accept a ternary operand (see {@link #tryTernaryComparison}). */ - private static final Set TERNARY_COMPARISONS = + /** + * The orderable/equality comparison operators (eq/ne/lt/gt/le/ge) — shared by the + * ternary rewrite, the arithmetic path, and the constant-vs-constant fold. + */ + private static final Set COMPARISON_OPS = Set.of("eq", "ne", "lt", "gt", "le", "ge"); /** - * Rewrite a comparison wrapping a CEL ternary — {@code cmp(if(c, a, b), other)} — into a - * pure predicate: + * A comparison wrapping a CEL ternary — {@code cmp(if(c, a, b), other)}. Each branch is + * substituted back into the comparison and recursed through {@link #traverseExpression}, + * so a ternary branch behaves identically to the same comparison written directly (see + * {@link #translateTernary} for the rewrite and its null semantics). Recursion also + * handles nested ternaries and a ternary on the other side for free. + * + * @return the rewritten predicate, or {@code null} if this comparison involves no ternary + */ + private Predicate tryTernaryComparison(String op, List operands, Scope scope) { + if (!COMPARISON_OPS.contains(op) || operands.size() != 2) { + return null; + } + int idx; + if (isIfExpression(operands.get(0))) { + idx = 0; + } else if (isIfExpression(operands.get(1))) { + idx = 1; + } else { + return null; + } + List ifOps = operands.get(idx).getExpression().getOperandsList(); + return translateTernary(ifOps, + branch -> traverseExpression(substituteOperand(op, operands, idx, branch), scope), + scope); + } + + private static boolean isIfExpression(Operand o) { + return o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "if".equals(o.getExpression().getOperator()); + } + + /** + * Rewrite a CEL ternary {@code if(c, a, b)} into a pure predicate: * - *

{@code (pred(c) AND cmp(a, other)) OR (NOT pred(c) AND cmp(b, other)) OR NOT(pred(c) OR NOT pred(c))}
+ *
{@code (pred(c) AND branch(a)) OR (NOT pred(c) AND branch(b)) OR NOT(pred(c) OR NOT pred(c))}
* - * We rewrite instead of emitting {@code CASE WHEN} ({@code cb.selectCase}) because this - * translator is predicate-only: every existing typed leaf path — field-first - * normalization, size() handling, add-fold, fractional double-space comparison — operates - * on comparison predicates. Substituting each branch back into the comparison and - * recursing through {@link #traverseExpression} routes the branches through those exact - * paths, so a ternary branch behaves identically to the same comparison written directly. - * Recursion also handles nested ternaries and a ternary on the other side for free. + * where {@code branch} is supplied by the caller — comparison substitution for + * {@link #tryTernaryComparison}, {@link #booleanBranchPredicate} for + * {@link #handleBareTernary}. We rewrite instead of emitting {@code CASE WHEN} + * ({@code cb.selectCase}) because this translator is predicate-only: every existing typed + * leaf path — field-first normalization, size() handling, add-fold, fractional + * double-space comparison — operates on comparison predicates, and routing the branches + * back through those exact paths keeps them identical to the same condition written + * directly. + * + *

A constant boolean condition folds to a single branch — only that branch is + * translated, so an untranslatable dead branch cannot fail the whole plan. * *

Null semantics: a null/missing condition in a CEL ternary is an evaluation error * and the check denies, so the SQL must evaluate to UNKNOWN — never FALSE — when the * condition column is NULL. The two branch arms alone are not enough: with both branch - * comparisons false they evaluate {@code (NULL AND FALSE) OR (NULL AND FALSE) = FALSE}, + * predicates false they evaluate {@code (NULL AND FALSE) OR (NULL AND FALSE) = FALSE}, * which {@code not(...)} flips to TRUE and leaks rows the PDP denies. The third arm * ({@link #unknownWhenConditionUnknown}) restores the missing UNKNOWN: it is FALSE for a * known condition (no effect on the OR) and UNKNOWN for a NULL one, driving the whole OR * to UNKNOWN so the row is excluded under BOTH polarities. * - * @return the rewritten predicate, or {@code null} if this comparison involves no ternary + *

The condition is translated fresh for each arm: Hibernate 6 negation is stateful + * (see {@link #negate}), so sharing one Predicate node between the positive and negated + * arms is unsafe. */ - private Predicate tryTernaryComparison(String op, List operands, Scope scope) { - if (!TERNARY_COMPARISONS.contains(op) || operands.size() != 2) { - return null; - } - int idx = -1; - for (int i = 0; i < operands.size(); i++) { - Operand o = operands.get(i); - if (o.getNodeCase() == Operand.NodeCase.EXPRESSION - && "if".equals(o.getExpression().getOperator())) { - idx = i; - break; - } - } - if (idx < 0) { - return null; - } - List ifOps = operands.get(idx).getExpression().getOperandsList(); + private Predicate translateTernary(List ifOps, + java.util.function.Function branchTranslator, + Scope scope) { if (ifOps.size() != 3) { throw new IllegalArgumentException( "if (ternary) requires exactly 3 operands (condition, then, else), got " @@ -271,28 +299,18 @@ private Predicate tryTernaryComparison(String op, List operands, Scope Operand thenBranch = ifOps.get(1); Operand elseBranch = ifOps.get(2); - // A constant boolean condition folds to a single branch — translate only that branch - // so an untranslatable dead branch cannot fail the whole plan. if (condition.getNodeCase() == Operand.NodeCase.VALUE) { Boolean known = constantBooleanOrNull(condition); if (known == null) { throw new IllegalArgumentException( "if (ternary) condition must be a boolean expression"); } - return traverseExpression( - substituteOperand(op, operands, idx, known ? thenBranch : elseBranch), scope); + return branchTranslator.apply(known ? thenBranch : elseBranch); } - Predicate thenCmp = traverseExpression( - substituteOperand(op, operands, idx, thenBranch), scope); - Predicate elseCmp = traverseExpression( - substituteOperand(op, operands, idx, elseBranch), scope); - // Translate the condition once per occurrence: Hibernate 6 negation is stateful (see - // negate()), so sharing one Predicate node between the positive and negated arms is - // unsafe. return cb.or( - cb.and(traverse(condition, scope), thenCmp), - cb.and(negate(traverse(condition, scope)), elseCmp), + cb.and(traverse(condition, scope), branchTranslator.apply(thenBranch)), + cb.and(negate(traverse(condition, scope)), branchTranslator.apply(elseBranch)), unknownWhenConditionUnknown(condition, scope)); } @@ -312,36 +330,12 @@ private Predicate unknownWhenConditionUnknown(Operand condition, Scope scope) { /** * A CEL ternary in boolean position — {@code if(c, a, b)} used directly as a condition, - * so both branches are themselves boolean. Same predicate rewrite (and same rationale and - * null semantics) as {@link #tryTernaryComparison}: - * - *

{@code (pred(c) AND pred(a)) OR (NOT pred(c) AND pred(b)) OR NOT(pred(c) OR NOT pred(c))}
+ * so both branches are themselves boolean and translate through + * {@link #booleanBranchPredicate}. Same rewrite, rationale and null semantics as + * {@link #translateTernary}. */ private Predicate handleBareTernary(List operands, Scope scope) { - if (operands.size() != 3) { - throw new IllegalArgumentException( - "if (ternary) requires exactly 3 operands (condition, then, else), got " - + operands.size()); - } - Operand condition = operands.get(0); - Operand thenBranch = operands.get(1); - Operand elseBranch = operands.get(2); - - // Constant boolean condition folds to a single branch — see tryTernaryComparison. - if (condition.getNodeCase() == Operand.NodeCase.VALUE) { - Boolean known = constantBooleanOrNull(condition); - if (known == null) { - throw new IllegalArgumentException( - "if (ternary) condition must be a boolean expression"); - } - return booleanBranchPredicate(known ? thenBranch : elseBranch, scope); - } - - // Translate the condition once per occurrence — see tryTernaryComparison. - return cb.or( - cb.and(traverse(condition, scope), booleanBranchPredicate(thenBranch, scope)), - cb.and(negate(traverse(condition, scope)), booleanBranchPredicate(elseBranch, scope)), - unknownWhenConditionUnknown(condition, scope)); + return translateTernary(operands, branch -> booleanBranchPredicate(branch, scope), scope); } /** @@ -395,7 +389,7 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc // Constant-vs-constant comparisons are statically evaluated. The planner never emits // them directly, but ternary substitution produces them — the else branch of // `(aBool ? aNumber : 0) > 0` becomes gt(value(0), value(0)). - if (TERNARY_COMPARISONS.contains(op) + if (COMPARISON_OPS.contains(op) && operands.get(0).getNodeCase() == Operand.NodeCase.VALUE && operands.get(1).getNodeCase() == Operand.NodeCase.VALUE) { return constantComparison(op, @@ -499,16 +493,12 @@ private Predicate handleLeafOperator(String op, List operands, Scope sc if (value == null) { // A registered override owns the operator's full translation, including a null RHS. - OperatorFunction override = overrides.get(op); - if (override != null) { - return override.apply(cb, path, null); - } - return switch (op) { + return withOverride(op, path, null, () -> switch (op) { case "eq" -> cb.isNull(path); case "ne" -> cb.isNotNull(path); default -> throw new IllegalArgumentException( "Null values are only supported with eq and ne operators (got " + op + ")"); - }; + }); } return applyLeaf(op, path, value); @@ -587,11 +577,31 @@ private static Object constantReceiverOrNull(Operand o) { * against another (contains/startsWith/endsWith). Operand source order is preserved — * two variables rank equally, so {@link NormalizedBinary} never swaps them. */ - @SuppressWarnings({"rawtypes", "unchecked"}) private Predicate fieldToFieldComparison(String op, String leftVar, String rightVar, Scope scope) { - jakarta.persistence.criteria.Expression left = scope.resolvePath(leftVar); - jakarta.persistence.criteria.Expression right = scope.resolvePath(rightVar); + jakarta.persistence.criteria.Expression left = scope.resolvePath(leftVar); + jakarta.persistence.criteria.Expression right = scope.resolvePath(rightVar); + return switch (op) { + case "eq", "ne", "lt", "gt", "le", "ge" -> comparePredicate(op, left, right); + case "contains" -> fieldToFieldLike(left, right, true, true); + case "startsWith" -> fieldToFieldLike(left, right, false, true); + case "endsWith" -> fieldToFieldLike(left, right, true, false); + default -> throw new IllegalArgumentException( + "Field-to-field comparison is not supported for operator '" + op + "': " + + leftVar + " vs " + rightVar); + }; + } + + /** + * Raw-typed comparison of two SQL expressions — the shared dispatch of field-to-field + * comparisons and arithmetic expression-vs-expression comparisons. Constant-RHS shapes + * do NOT route here: they bind through the plain-value overloads on purpose (double + * bind parameters — see {@link #tryArithmeticComparison}). + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Predicate comparePredicate(String op, + jakarta.persistence.criteria.Expression left, + jakarta.persistence.criteria.Expression right) { return switch (op) { case "eq" -> cb.equal(left, right); case "ne" -> cb.notEqual(left, right); @@ -599,12 +609,8 @@ private Predicate fieldToFieldComparison(String op, String leftVar, String right case "gt" -> cb.greaterThan(left, right); case "le" -> cb.lessThanOrEqualTo(left, right); case "ge" -> cb.greaterThanOrEqualTo(left, right); - case "contains" -> fieldToFieldLike(left, right, true, true); - case "startsWith" -> fieldToFieldLike(left, right, false, true); - case "endsWith" -> fieldToFieldLike(left, right, true, false); default -> throw new IllegalArgumentException( - "Field-to-field comparison is not supported for operator '" + op + "': " - + leftVar + " vs " + rightVar); + "Unsupported arithmetic comparison operator: " + op); }; } @@ -649,11 +655,22 @@ private Predicate fieldToFieldLike(jakarta.persistence.criteria.Expression ha * comparison, {@code add}-folded comparison, and bare-boolean — not just the direct one. */ private Predicate applyLeaf(String op, Path path, Object value) { + return withOverride(op, path, value, () -> defaultLeaf(op, path, value)); + } + + /** + * Route a scalar (field, value) translation through the per-operator {@code overrides} + * hook: a registered {@link OperatorFunction} owns the operator's full translation + * (mirrored operators are consulted under the mirrored name — see + * {@link NormalizedBinary}); otherwise the supplied default applies. + */ + private Predicate withOverride(String op, jakarta.persistence.criteria.Expression field, + Object value, Supplier dflt) { OperatorFunction override = overrides.get(op); if (override != null) { - return override.apply(cb, path, value); + return override.apply(cb, field, value); } - return defaultLeaf(op, path, value); + return dflt.get(); } @SuppressWarnings({"rawtypes", "unchecked"}) @@ -724,7 +741,7 @@ private Predicate defaultLeaf(String op, Path path, Object value) { * also handles string concatenation and the override hooks) */ private Predicate tryArithmeticComparison(String op, List operands, Scope scope) { - if (!TERNARY_COMPARISONS.contains(op) || operands.size() != 2) { + if (!COMPARISON_OPS.contains(op) || operands.size() != 2) { return null; } boolean hasArith = operands.stream().anyMatch(o -> @@ -760,15 +777,12 @@ private Predicate tryArithmeticComparison(String op, List operands, Sco ((NumericOperand.Sql) left).expr(); if (right instanceof NumericOperand.Constant rc) { - OperatorFunction override = overrides.get(op); - if (override != null) { - return override.apply(cb, lhs, rc.value()); - } // Plain-value overloads bind the constant as a genuine double PARAMETER; a // cb.literal would inline `0.3`, which H2/Postgres type as exact NUMERIC and // drag the comparison out of IEEE space (see resolveNumericOperand). + String cmpOp = op; double v = rc.value(); - return switch (op) { + return withOverride(cmpOp, lhs, rc.value(), () -> switch (cmpOp) { case "eq" -> cb.equal(lhs, v); case "ne" -> cb.notEqual(lhs, v); case "lt" -> cb.lt(lhs, v); @@ -776,22 +790,13 @@ private Predicate tryArithmeticComparison(String op, List operands, Sco case "le" -> cb.le(lhs, v); case "ge" -> cb.ge(lhs, v); default -> throw new IllegalArgumentException( - "Unsupported arithmetic comparison operator: " + op); - }; + "Unsupported arithmetic comparison operator: " + cmpOp); + }); } jakarta.persistence.criteria.Expression rhs = ((NumericOperand.Sql) right).expr(); - return switch (op) { - case "eq" -> cb.equal(lhs, rhs); - case "ne" -> cb.notEqual(lhs, rhs); - case "lt" -> cb.lt(lhs, rhs); - case "gt" -> cb.gt(lhs, rhs); - case "le" -> cb.le(lhs, rhs); - case "ge" -> cb.ge(lhs, rhs); - default -> throw new IllegalArgumentException( - "Unsupported arithmetic comparison operator: " + op); - }; + return comparePredicate(op, lhs, rhs); } /** @@ -1059,15 +1064,18 @@ else if (o.getNodeCase() == Operand.NodeCase.VALUE) { throw new IllegalArgumentException("Invalid isSet operands"); } Path path = scope.resolvePath(variable); - OperatorFunction override = overrides.get("isSet"); - if (override != null) { - return override.apply(cb, path, flag); - } - return flag ? cb.isNotNull(path) : cb.isNull(path); + boolean isSet = flag; + return withOverride("isSet", path, isSet, + () -> isSet ? cb.isNotNull(path) : cb.isNull(path)); } // -- in (set membership or collection membership) -- + /** Wrap a scalar plan constant as a single-element list; lists pass through unchanged. */ + private static List asList(Object val) { + return (val instanceof List l) ? l : List.of(val); + } + private Predicate handleIn(List rawOperands, Scope scope) { if (rawOperands.size() != 2) { throw new IllegalArgumentException("in requires exactly 2 operands"); @@ -1088,22 +1096,19 @@ private Predicate handleIn(List rawOperands, Scope scope) { Scope.ResolvedRelation relRef = scope.resolveRelation(var); if (relRef != null) { - List values = (val instanceof List l) ? l : List.of(val); - return collectionContainsAny(scope, relRef, values); + return collectionContainsAny(scope, relRef, asList(val)); } Path path = scope.resolvePath(var); - OperatorFunction override = overrides.get("in"); - if (override != null) { - return override.apply(cb, path, val); - } - if (val instanceof List list) { - if (list.isEmpty()) { - return cb.disjunction(); + return withOverride("in", path, val, () -> { + if (val instanceof List list) { + if (list.isEmpty()) { + return cb.disjunction(); + } + return path.in(list); } - return path.in(list); - } - return cb.equal(path, val); + return cb.equal(path, val); + }); } // -- hasIntersection -- @@ -1123,7 +1128,7 @@ private Predicate handleHasIntersection(List rawOperands, Scope scope) && second.getNodeCase() == Operand.NodeCase.VALUE) { String var = first.getVariable(); Object val = PlanValues.protoValueToJava(second.getValue()); - List values = (val instanceof List l) ? l : List.of(val); + List values = asList(val); Scope.ResolvedRelation relRef = scope.resolveRelation(var); if (relRef != null) { @@ -1144,14 +1149,38 @@ private Predicate handleHasIntersection(List rawOperands, Scope scope) "hasIntersection second operand must be a value list when used with map()"); } Object val = PlanValues.protoValueToJava(second.getValue()); - List values = (val instanceof List l) ? l : List.of(val); - return handleMapIntersection(first.getExpression(), values, scope); + return handleMapIntersection(first.getExpression(), asList(val), scope); } throw new IllegalArgumentException( "Unsupported hasIntersection operand shape: " + first.getNodeCase()); } + /** A parsed CEL lambda operand: its body and the name of its iteration variable. */ + private record ParsedLambda(Operand body, String varName) {} + + /** + * Validate and unpack a {@code lambda(body, var)} operand — an EXPRESSION with operator + * {@code lambda}, exactly two operands, the second a VARIABLE. Error messages are + * caller-supplied so each operator keeps its exact wording. + */ + private static ParsedLambda parseLambda(Operand lambdaOperand, String notLambdaMessage, + String arityMessage, String varMessage) { + if (lambdaOperand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"lambda".equals(lambdaOperand.getExpression().getOperator())) { + throw new IllegalArgumentException(notLambdaMessage); + } + List lambdaOps = lambdaOperand.getExpression().getOperandsList(); + if (lambdaOps.size() != 2) { + throw new IllegalArgumentException(arityMessage); + } + Operand varOp = lambdaOps.get(1); + if (varOp.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException(varMessage); + } + return new ParsedLambda(lambdaOps.get(0), varOp.getVariable()); + } + /** Translate {@code hasIntersection(map(collection, lambda), values)}. */ private Predicate handleMapIntersection(PlanResourcesFilter.Expression mapExpr, List values, Scope scope) { @@ -1170,24 +1199,18 @@ private Predicate handleMapIntersection(PlanResourcesFilter.Expression mapExpr, if (collectionOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { throw new IllegalArgumentException("map first operand must be a variable"); } - if (lambdaOperand.getNodeCase() != Operand.NodeCase.EXPRESSION - || !"lambda".equals(lambdaOperand.getExpression().getOperator())) { - throw new IllegalArgumentException("map second operand must be a lambda"); - } - String collectionVar = collectionOperand.getVariable(); - List lambdaOps = lambdaOperand.getExpression().getOperandsList(); - if (lambdaOps.size() != 2) { - throw new IllegalArgumentException("map lambda requires exactly 2 operands (body, variable)"); - } - Operand projection = lambdaOps.get(0); - Operand lambdaVar = lambdaOps.get(1); - if (projection.getNodeCase() != Operand.NodeCase.VARIABLE - || lambdaVar.getNodeCase() != Operand.NodeCase.VARIABLE) { + ParsedLambda lambda = parseLambda(lambdaOperand, + "map second operand must be a lambda", + "map lambda requires exactly 2 operands (body, variable)", + "map lambda body must be a simple variable projection"); + // map()'s extra shape constraint: the body must project a plain member variable. + Operand projection = lambda.body(); + if (projection.getNodeCase() != Operand.NodeCase.VARIABLE) { throw new IllegalArgumentException("map lambda body must be a simple variable projection"); } - String memberField = Scope.extractLambdaSuffix(projection.getVariable(), lambdaVar.getVariable()); + String memberField = Scope.extractLambdaSuffix(projection.getVariable(), lambda.varName()); // Resolve the collection to its owner-anchored join chain. Single Relations and // dotted chains ("request.resource.attr.categories.subCategories") share one path: @@ -1244,18 +1267,27 @@ private Predicate collectionContainsAny(Scope scope, Scope.ResolvedRelation ref, /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ private Predicate trySizeComparison(String op, List operands, Scope scope) { + // Detect the size() operand first: every ordinary leaf comparison probes through + // here, and converting the VALUE operand up front would materialize lists/structs + // only to discard them when no size() expression is present. PlanResourcesFilter.Expression sizeExpr = null; - Double numRaw = null; for (Operand o : operands) { if (o.getNodeCase() == Operand.NodeCase.EXPRESSION && "size".equals(o.getExpression().getOperator())) { sizeExpr = o.getExpression(); - } else if (o.getNodeCase() == Operand.NodeCase.VALUE) { - Object v = PlanValues.protoValueToJava(o.getValue()); - if (v instanceof Number n) numRaw = n.doubleValue(); } } - if (sizeExpr == null || numRaw == null) { + if (sizeExpr == null) { + return null; + } + Double numRaw = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.VALUE + && o.getValue().getKindCase() == Value.KindCase.NUMBER_VALUE) { + numRaw = o.getValue().getNumberValue(); + } + } + if (numRaw == null) { return null; } @@ -1304,19 +1336,16 @@ private Predicate trySizeComparison(String op, List operands, Scope sco // size(coll.filter(x, pred)) — count only the elements matching the lambda. List filterOps = sizeArg.getExpression().getOperandsList(); if (filterOps.size() != 2 - || filterOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE - || filterOps.get(1).getNodeCase() != Operand.NodeCase.EXPRESSION - || !"lambda".equals(filterOps.get(1).getExpression().getOperator())) { + || filterOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE) { throw new IllegalArgumentException("Unsupported size(filter(...)) expression"); } var = filterOps.get(0).getVariable(); - List lambdaOps = filterOps.get(1).getExpression().getOperandsList(); - if (lambdaOps.size() != 2 - || lambdaOps.get(1).getNodeCase() != Operand.NodeCase.VARIABLE) { - throw new IllegalArgumentException("lambda requires exactly 2 operands"); - } - lambdaBody = lambdaOps.get(0); - lambdaVarName = lambdaOps.get(1).getVariable(); + ParsedLambda lambda = parseLambda(filterOps.get(1), + "Unsupported size(filter(...)) expression", + "lambda requires exactly 2 operands", + "lambda requires exactly 2 operands"); + lambdaBody = lambda.body(); + lambdaVarName = lambda.varName(); } else { throw new IllegalArgumentException("Unsupported size() expression"); } @@ -1347,12 +1376,6 @@ private Predicate trySizeComparison(String op, List operands, Scope sco fBody == null ? cb.conjunction() : traverse(fBody, Scope.lambda(tailJoin, sub, ref.tail(), fVar, rebased)); - boolean nonEmpty = ("gt".equals(cmpOp) && numValue == 0L) - || ("ge".equals(cmpOp) && numValue == 1L); - boolean empty = ("eq".equals(cmpOp) && numValue == 0L) - || ("le".equals(cmpOp) && numValue == 0L) - || ("lt".equals(cmpOp) && numValue == 1L); - Predicate base; if (fractionalCollapse != null) { // A Relation count is always defined (an empty join is count 0), so the @@ -1360,20 +1383,27 @@ private Predicate trySizeComparison(String op, List operands, Scope sco // size(filter(...)) unknown-element guard below so an erroring lambda body // still denies the row. base = fractionalCollapse ? cb.conjunction() : cb.disjunction(); - } else if (nonEmpty) { - base = existsSubquery(scope, ref, bodyBuilder); - } else if (empty) { - base = negate(existsSubquery(scope, ref, bodyBuilder)); } else { - // Arbitrary N → correlated (SELECT COUNT(...)) N, same shape as exists_one. - // For a multi-hop chain the COUNT joins through every hop, so it counts the - // FLATTENED tail elements — the same element set the EXISTS shortcuts range over. - ChainSubquery cs = chainSubquery(Long.class, scope, ref); - cs.sub().select(cb.count(cs.tailJoin())); - if (fBody != null) { - cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); + boolean nonEmpty = ("gt".equals(cmpOp) && numValue == 0L) + || ("ge".equals(cmpOp) && numValue == 1L); + boolean empty = ("eq".equals(cmpOp) && numValue == 0L) + || ("le".equals(cmpOp) && numValue == 0L) + || ("lt".equals(cmpOp) && numValue == 1L); + if (nonEmpty) { + base = existsSubquery(scope, ref, bodyBuilder); + } else if (empty) { + base = negate(existsSubquery(scope, ref, bodyBuilder)); + } else { + // Arbitrary N → correlated (SELECT COUNT(...)) N, same shape as + // exists_one. For a multi-hop chain the COUNT joins through every hop, so it + // counts the FLATTENED tail elements — the same element set the EXISTS + // shortcuts range over. + ChainSubquery cs = countSubquery(scope, ref); + if (fBody != null) { + cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); + } + base = compareCount(cs.sub(), cmpOp, numValue); } - base = compareCount(cs.sub(), cmpOp, numValue); } if (fBody == null) { // size(collection) counts rows without evaluating a lambda — no element can be @@ -1428,9 +1458,12 @@ private > Predicate compareCount( * {@link #unknownPredicate} (a constant SQL UNKNOWN to compose with). * *

{@code filter}/{@code except} in boolean position are kept consistent with the - * {@code exists} family. Cost note: the unknown machinery (two correlated COUNT - * subqueries) is always emitted — the attribute mapping carries no column-nullability - * metadata, so a NULL-free lambda body cannot be detected statically. + * {@code exists} family. Cost note: the unknown machinery is always emitted — each + * {@link #unknownElementExists} probe is two correlated COUNT subqueries, and + * {@code exists_one} (like the arbitrary-N {@code size(filter(...))} shape) emits the + * probe twice, i.e. five correlated subqueries including the base COUNT — the attribute + * mapping carries no column-nullability metadata, so a NULL-free lambda body cannot be + * detected statically. */ private Predicate handleCollectionOperator(String op, List operands, Scope scope) { if (operands.size() != 2) { @@ -1457,16 +1490,12 @@ private Predicate handleCollectionOperator(String op, List operands, Sc op + " requires a Relation mapping for " + collectionVar); } - List lambdaOps = lambdaOperand.getExpression().getOperandsList(); - if (lambdaOps.size() != 2) { - throw new IllegalArgumentException("lambda requires exactly 2 operands"); - } - Operand body = lambdaOps.get(0); - Operand lambdaVar = lambdaOps.get(1); - if (lambdaVar.getNodeCase() != Operand.NodeCase.VARIABLE) { - throw new IllegalArgumentException("lambda variable must be a variable operand"); - } - String lambdaVarName = lambdaVar.getVariable(); + ParsedLambda lambda = parseLambda(lambdaOperand, + op + " second operand must be a lambda", + "lambda requires exactly 2 operands", + "lambda variable must be a variable operand"); + Operand body = lambda.body(); + String lambdaVarName = lambda.varName(); // Every invocation re-traverses the body, so each occurrence gets a fresh Predicate // tree (Hibernate 6 negation is stateful — see negate()). @@ -1499,8 +1528,7 @@ private Predicate handleCollectionOperator(String op, List operands, Sc // ≥1 UNKNOWN element → (… AND FALSE) OR (TRUE AND UNKNOWN) = UNKNOWN (deny) // no UNKNOWN element → (COUNT=1 AND TRUE) OR (FALSE AND …) = COUNT=1 case "exists_one" -> { - ChainSubquery cs = chainSubquery(Long.class, scope, ref); - cs.sub().select(cb.count(cs.tailJoin())); + ChainSubquery cs = countSubquery(scope, ref); cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); yield cb.or( cb.and(cb.equal(cs.sub(), 1L), @@ -1525,11 +1553,9 @@ private Predicate handleCollectionOperator(String op, List operands, Sc */ private Predicate unknownElementExists(Scope scope, Scope.ResolvedRelation ref, SubqueryBodyBuilder bodyBuilder) { - ChainSubquery total = chainSubquery(Long.class, scope, ref); - total.sub().select(cb.count(total.tailJoin())); + ChainSubquery total = countSubquery(scope, ref); - ChainSubquery determined = chainSubquery(Long.class, scope, ref); - determined.sub().select(cb.count(determined.tailJoin())); + ChainSubquery determined = countSubquery(scope, ref); determined.sub().where(cb.or( bodyBuilder.build(determined.sub(), determined.tailJoin(), determined.rebasedOuter()), negate(bodyBuilder.build(determined.sub(), determined.tailJoin(), determined.rebasedOuter())))); @@ -1613,6 +1639,13 @@ private ChainSubquery chainSubquery(Class resultType, Scope scope, return new ChainSubquery<>(sub, join, rebased); } + /** A chain subquery seeded to {@code SELECT COUNT(tailJoin)} — the shared seed of every counting shape. */ + private ChainSubquery countSubquery(Scope scope, Scope.ResolvedRelation ref) { + ChainSubquery cs = chainSubquery(Long.class, scope, ref); + cs.sub().select(cb.count(cs.tailJoin())); + return cs; + } + private Predicate existsSubquery(Scope scope, Scope.ResolvedRelation ref, SubqueryBodyBuilder bodyBuilder) { ChainSubquery cs = chainSubquery(Integer.class, scope, ref); diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 9db54c4c..c9aa8ced 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -804,9 +804,8 @@ void manyToOneTraversal() { @Test void isSetNested() { // request.resource.attr.nested.aOptionalString != null - // Only r1's nested has aOptionalString set... actually we didn't set it, so all are null. - // To actually test this, set on r1's nested. We do it via a separate test setup. - // Here we just verify the predicate compiles and runs without error. + // No seeded row sets nested.aOptionalString, so the result is empty: this verifies + // the nested-path predicate translates and executes, not a positive match. assertEquals(List.of(), runWithMapping("is-set-nested", FIELD_MAP)); } } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index 90cbe05e..d4a1f06e 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -1311,6 +1311,14 @@ void ancestorOfConstantsSatisfied() { hierarchy(sval("a"), ":"), hierarchy(sval("a:b"), ":")); assertEquals(0, runCount(cond)); + + // A trailing delimiter is a real (empty) segment: "a:b:" splits to ["a","b",""], + // so "a:b" is still a strict prefix. If splitLiteral dropped trailing empties this + // would throw "do not satisfy" instead of translating to always-true. + Operand trailing = exprOp("ancestorOf", + hierarchy(sval("a:b"), ":"), + hierarchy(sval("a:b:"), ":")); + assertEquals(0, runCount(trailing)); } @Test From cafe6eee1af48ab034ef228d9fc7c5b47c789047 Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Tue, 21 Jul 2026 09:01:45 +0100 Subject: [PATCH 19/20] fix(spring-data): keep out-of-long-range whole-number constants as doubles Casting a double outside [-2^63, 2^63) to long saturates (JLS 5.1.3), so a policy constant like 1.0e19 silently became Long.MAX_VALUE and inverted comparisons against nearby constants. Range-guard the long coercion in protoValueToJava; out-of-range values stay doubles, which every comparison path already handles in double space. Red test: 1.0e19 > 9.3e18 folded to false under saturation. Signed-off-by: Alex Olivier --- .../java/dev/cerbos/queryplan/springdata/PlanValues.java | 7 ++++++- .../springdata/SpringDataQueryPlanAdapterTest.java | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java index f586f602..26fb8423 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java @@ -18,7 +18,12 @@ static Object protoValueToJava(Value value) { case STRING_VALUE -> value.getStringValue(); case NUMBER_VALUE -> { double d = value.getNumberValue(); - if (d == Math.floor(d) && !Double.isInfinite(d)) { + // Whole numbers become longs only inside [-2^63, 2^63): casting a double + // outside that range saturates to Long.MIN/MAX_VALUE (JLS 5.1.3), silently + // changing the constant. Out-of-range values stay doubles, which every + // comparison path already handles in double space. + if (d == Math.floor(d) && !Double.isInfinite(d) + && d >= -0x1p63 && d < 0x1p63) { yield (long) d; } yield d; diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index d4a1f06e..aa1d22a3 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -1751,6 +1751,12 @@ void constantVersusConstantComparisonsFold() { assertEquals(0, runCount(exprOp("eq", sval("a"), nval(1)))); assertEquals(1, runCount(exprOp("ne", sval("a"), nval(1)))); assertEquals(1, runCount(exprOp("eq", bval(true), bval(true)))); + // Whole-number constants beyond the long range must stay doubles: a + // saturating (long) cast collapses 1.0e19 and 9.3e18 both to + // Long.MAX_VALUE, inverting these comparisons. + assertEquals(1, runCount(exprOp("gt", nval(1.0e19), nval(9.3e18)))); + assertEquals(1, runCount(exprOp("ne", nval(1.0e19), nval(9.3e18)))); + assertEquals(0, runCount(exprOp("eq", nval(-1.0e19), nval(-9.3e18)))); // Ordering incomparable constant types is a planner bug and must throw. assertConditionThrows(exprOp("lt", sval("a"), nval(1)), "Cannot order", "lt"); From 22ed6bb5fdcf8566321a26ae35ce4328dc4a875c Mon Sep 17 00:00:00 2001 From: Alex Olivier Date: Tue, 21 Jul 2026 09:53:20 +0100 Subject: [PATCH 20/20] refactor(spring-data): deepen tri-state algebra and comparison translation into modules Architecture review follow-through (candidates 1 and 2), behavior-preserving: - TriPredicate: the error->deny/UNKNOWN algebra is its own module with its own unit seam (12 new tests pinning truth tables, junction barrier, and fresh-per-occurrence invocation counts). Multi-polarity inputs are Suppliers, so sharing a Predicate node across polarities is impossible at call sites; cb.not has exactly one call site, inside the module. - ComparisonTranslator: binary leaf comparisons flow through one seam that resolves operands to typed Resolved cases (Constant | Field | ConstantAdd | FieldPlusConstant | Arithmetic | Opaque) and dispatches on the pair. Deletes the five-probe order-as-spec chain, the 125-line operand collector, and the 22-line addFoldSolveOwns ownership referee. Extension recipe (timestamp() worked example) documented on the module. - size() translation and the ternary rewrite stay dedicated internal steps behind the single entry point (oracle-pinned SQL shapes). - CONTEXT.md records the adapter vocabulary. 356 tests green (unit 160, tri-state 12, integration 104, oracle 80); exception messages byte-identical; override contract unchanged. Signed-off-by: Alex Olivier --- spring-data/CONTEXT.md | 38 + .../SpringDataQueryPlanAdapter.java | 2045 +++++++++-------- .../queryplan/springdata/TriPredicate.java | 159 ++ .../springdata/SpringDataIntegrationTest.java | 4 +- .../SpringDataQueryPlanAdapterTest.java | 16 +- .../springdata/TriPredicateTest.java | 295 +++ 6 files changed, 1555 insertions(+), 1002 deletions(-) create mode 100644 spring-data/CONTEXT.md create mode 100644 spring-data/src/main/java/dev/cerbos/queryplan/springdata/TriPredicate.java create mode 100644 spring-data/src/test/java/dev/cerbos/queryplan/springdata/TriPredicateTest.java diff --git a/spring-data/CONTEXT.md b/spring-data/CONTEXT.md new file mode 100644 index 00000000..4fbc8858 --- /dev/null +++ b/spring-data/CONTEXT.md @@ -0,0 +1,38 @@ +# Domain glossary — spring-data adapter + +Terms used by this adapter's code, tests, and reviews. Architecture vocabulary +(module / interface / seam / depth) follows the codebase-design convention. + +- **Error→deny contract** — the adapter's semantic target: the filtered row set + equals what per-resource `check()` calls would allow. CEL evaluation errors + (null/missing attribute without a null overload) deny, so their SQL + translation must evaluate UNKNOWN — never FALSE — under every polarity. +- **TriPredicate** — the tri-state predicate algebra module enforcing that + contract structurally: it owns the UNKNOWN constant, the junction-barriered + negation (Hibernate 6 collapses `cb.not(cb.not(p))`), and the macro truth + tables. Inputs consumed in more than one polarity are `Supplier`s, so + "translate fresh per occurrence" cannot be violated by callers. `cb.not` has + exactly one call site: inside this module. +- **ComparisonTranslator / Resolved** — the single comparison-translation seam. + Every binary leaf comparison resolves each operand to a typed `Resolved` case + (`Constant`, `Field`, `ConstantAdd`, `FieldPlusConstant`, `Arithmetic`, + `Opaque`) and dispatches on the pair. New operand types (e.g. `timestamp()`) + are one resolver case + dispatch pairings — see the extension recipe in the + module Javadoc. Classification is structural; conversion is lazy, because + which error fires is part of the pinned interface. +- **NormalizedBinary** — planner operands arrive in policy source order + (`1 < R.attr.x` is value-first); this normalizes field-first and mirrors + directional operators (`lt`↔`gt`). Receiver-sensitive operators + (`contains`/`startsWith`/`endsWith`) are exempt — the receiver's position is + meaning, not noise. Overrides observe the mirrored operator name. +- **ChainSubquery** — the one correlated-subquery skeleton. It anchors + correlation at the scope that owns the relation and joins through every hop + of a multi-hop chain; all collection operators compose over it. +- **Differential oracle** — the adversarial conformance suite: hostile policy + shapes planned against a real PDP, translated, executed on H2, and the id set + compared row-by-row against `check()` with attributes mirroring the DB rows + exactly. DB NULL is a *missing* attribute on the check side. No + hand-computed expectations; a degeneracy guard prevents vacuous passes. +- **Double space** — all numeric work happens in IEEE doubles, because Cerbos + attribute numbers are CEL doubles and the wire plan erases `1` vs `1.0`. + Constants fold in Java; columns get a real `CAST(... AS DOUBLE)`. diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 52a86e9a..76d0355b 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -84,11 +84,14 @@ public static Result toSpecification( private static final class Translator { private final CriteriaBuilder cb; + private final TriPredicate tri; private final Map overrides; private final HierarchyTranslator hierarchy; + private final ComparisonTranslator comparisons = new ComparisonTranslator(); Translator(CriteriaBuilder cb, Map overrides) { this.cb = cb; + this.tri = new TriPredicate(cb); this.overrides = overrides; this.hierarchy = new HierarchyTranslator(cb); } @@ -106,17 +109,6 @@ private Predicate handleBareVariable(String variable, Scope scope) { return applyLeaf("eq", path, true); } - /** - * Logical negation with a junction barrier. Hibernate 6's SQM negation is stateful for - * comparison predicates: {@code cb.not(cb.not(p))} stays negated instead of toggling - * back (verified against Hibernate 6.6.18 — a double-negated {@code eq} still renders - * a single {@code NOT}). Wrapping in a single-element conjunction gives each {@code not} - * a fresh node to negate, so nested negations compose correctly. - */ - private Predicate negate(Predicate p) { - return cb.not(cb.and(p)); - } - private Predicate traverseExpression(PlanResourcesFilter.Expression expression, Scope scope) { String op = expression.getOperator(); List operands = expression.getOperandsList(); @@ -130,7 +122,7 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, if (operands.size() != 1) { throw new IllegalArgumentException("not requires exactly 1 operand"); } - yield negate(traverse(operands.get(0), scope)); + yield tri.not(traverse(operands.get(0), scope)); } case "exists", "exists_one", "all", "except", "filter" -> handleCollectionOperator(op, operands, scope); @@ -138,26 +130,11 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, case "hasIntersection", "has_intersection" -> handleHasIntersection(operands, scope); case "isSet" -> handleIsSet(operands, scope); case "in" -> handleIn(operands, scope); - case "if" -> handleBareTernary(operands, scope); + case "if" -> comparisons.handleBareTernary(operands, scope); case "overlaps" -> hierarchy.handleOverlaps(operands, scope); case "ancestorOf" -> hierarchy.handleAncestorDescendant(operands, scope, true); case "descendentOf" -> hierarchy.handleAncestorDescendant(operands, scope, false); - default -> { - Predicate ternaryPred = tryTernaryComparison(op, operands, scope); - if (ternaryPred != null) { - yield ternaryPred; - } - NormalizedBinary nb = NormalizedBinary.of(op, operands); - Predicate sizePred = trySizeComparison(nb.op(), nb.operands(), scope); - if (sizePred != null) { - yield sizePred; - } - Predicate arithPred = tryArithmeticComparison(nb.op(), nb.operands(), scope); - if (arithPred != null) { - yield arithPred; - } - yield handleLeafOperator(nb.op(), nb.operands(), scope); - } + default -> comparisons.translate(op, operands, scope); }; } @@ -178,7 +155,7 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, * the constant is the haystack — swapping it would silently invert haystack and needle * (translating {@code x LIKE '%a,b%'} instead of testing whether {@code "a,b"} contains * the column value). Those keep planner source order and are handled positionally by - * {@link #handleLeafOperator}. + * the constant-receiver case of {@link ComparisonTranslator#dispatch}. */ private record NormalizedBinary(String op, List operands) { @@ -216,831 +193,1119 @@ private static String mirror(String op) { } } - // -- if (CEL ternary) -- - /** - * The orderable/equality comparison operators (eq/ne/lt/gt/le/ge) — shared by the - * ternary rewrite, the arithmetic path, and the constant-vs-constant fold. + * Apply a scalar leaf operator, consulting the per-operator {@code overrides} hook first so a + * registered {@link OperatorFunction} wins on EVERY path that produces this operator — direct + * comparison, {@code add}-folded comparison, and bare-boolean — not just the direct one. */ - private static final Set COMPARISON_OPS = - Set.of("eq", "ne", "lt", "gt", "le", "ge"); + private Predicate applyLeaf(String op, Path path, Object value) { + return withOverride(op, path, value, () -> defaultLeaf(op, path, value)); + } /** - * A comparison wrapping a CEL ternary — {@code cmp(if(c, a, b), other)}. Each branch is - * substituted back into the comparison and recursed through {@link #traverseExpression}, - * so a ternary branch behaves identically to the same comparison written directly (see - * {@link #translateTernary} for the rewrite and its null semantics). Recursion also - * handles nested ternaries and a ternary on the other side for free. - * - * @return the rewritten predicate, or {@code null} if this comparison involves no ternary + * Route a scalar (field, value) translation through the per-operator {@code overrides} + * hook: a registered {@link OperatorFunction} owns the operator's full translation + * (mirrored operators are consulted under the mirrored name — see + * {@link NormalizedBinary}); otherwise the supplied default applies. */ - private Predicate tryTernaryComparison(String op, List operands, Scope scope) { - if (!COMPARISON_OPS.contains(op) || operands.size() != 2) { - return null; - } - int idx; - if (isIfExpression(operands.get(0))) { - idx = 0; - } else if (isIfExpression(operands.get(1))) { - idx = 1; - } else { - return null; + private Predicate withOverride(String op, jakarta.persistence.criteria.Expression field, + Object value, Supplier dflt) { + OperatorFunction override = overrides.get(op); + if (override != null) { + return override.apply(cb, field, value); } - List ifOps = operands.get(idx).getExpression().getOperandsList(); - return translateTernary(ifOps, - branch -> traverseExpression(substituteOperand(op, operands, idx, branch), scope), - scope); + return dflt.get(); } - private static boolean isIfExpression(Operand o) { - return o.getNodeCase() == Operand.NodeCase.EXPRESSION - && "if".equals(o.getExpression().getOperator()); + @SuppressWarnings({"rawtypes", "unchecked"}) + private Predicate defaultLeaf(String op, Path path, Object value) { + // Fractional constants compare in double space: protoValueToJava yields Double only + // for non-whole numbers, and Hibernate refuses to coerce e.g. 1.5 into an + // Integer-typed path ("not a whole number") — but `intColumn >= 1.5` is legal CEL + // that the planner emits verbatim. + jakarta.persistence.criteria.Expression raw = + (value instanceof Double) ? path.as(Double.class) : path; + return switch (op) { + case "eq" -> cb.equal(raw, value); + case "ne" -> cb.notEqual(raw, value); + case "lt" -> cb.lessThan(raw, (Comparable) value); + case "gt" -> cb.greaterThan(raw, (Comparable) value); + case "le" -> cb.lessThanOrEqualTo(raw, (Comparable) value); + case "ge" -> cb.greaterThanOrEqualTo(raw, (Comparable) value); + case "contains" -> cb.like(path.as(String.class), + "%" + PlanValues.escapeLike(String.valueOf(value)) + "%", '\\'); + case "startsWith" -> cb.like(path.as(String.class), + PlanValues.escapeLike(String.valueOf(value)) + "%", '\\'); + case "endsWith" -> cb.like(path.as(String.class), + "%" + PlanValues.escapeLike(String.valueOf(value)), '\\'); + default -> throw new IllegalArgumentException("Unsupported operator: " + op); + }; } /** - * Rewrite a CEL ternary {@code if(c, a, b)} into a pure predicate: - * - *

{@code (pred(c) AND branch(a)) OR (NOT pred(c) AND branch(b)) OR NOT(pred(c) OR NOT pred(c))}
+ * The comparison-translation module: every leaf comparison — plain {@code field op value}, + * field-to-field, constant-vs-constant, constant-receiver string matches, arithmetic, + * ternary-wrapped and {@code size()} comparisons — enters through {@link #translate} and + * nowhere else. Inside, one operand-resolution seam ({@link #resolve}) classifies each + * operand into a {@link Resolved} shape, and {@link #dispatch} translates the resolved + * pair; predicate-level rewrites (the CEL ternary, the eq/ne string-concat solve) are + * explicit steps in {@code translate}/{@code dispatch}, ordered by code structure. What + * this replaces: a chain of order-dependent probes (ternary → size → arithmetic → a leaf + * collector loop) where each probe re-scanned the raw operands and an ownership referee + * decided whether the {@code add} fold/solve path or the arithmetic path translated a + * given shape — the ordering was the specification, and it lived in comments. * - * where {@code branch} is supplied by the caller — comparison substitution for - * {@link #tryTernaryComparison}, {@link #booleanBranchPredicate} for - * {@link #handleBareTernary}. We rewrite instead of emitting {@code CASE WHEN} - * ({@code cb.selectCase}) because this translator is predicate-only: every existing typed - * leaf path — field-first normalization, size() handling, add-fold, fractional - * double-space comparison — operates on comparison predicates, and routing the branches - * back through those exact paths keeps them identical to the same condition written - * directly. + *

Design note — rejected alternative: an eagerly-converting resolver + * ({@code resolve(operand) -> Constant(javaValue) | Column(path) | NumericSql(expr)}) + * that folds {@code add(value, value)} with {@link PlanValues#foldAdd} and converts + * VALUES/paths at classification time was sketched first. It was rejected because + * conversion errors are part of the observable contract: WHICH message a malformed + * operand raises depends on the whole comparison's shape (a boolean inside {@code add} + * is a foldAdd type error against a field but "Arithmetic comparison requires numeric + * operands" against a constant; an unknown attribute must not preempt an "Unexpected + * X() expression" on the sibling operand), so eager conversion either re-orders pinned + * messages or forces the resolver to take a context parameter — which reintroduces the + * caller-knows-best coupling the seam exists to remove. The chosen shape classifies + * structurally and converts lazily at the dispatch site that consumes the operand. * - *

A constant boolean condition folds to a single branch — only that branch is - * translated, so an untranslatable dead branch cannot fail the whole plan. - * - *

Null semantics: a null/missing condition in a CEL ternary is an evaluation error - * and the check denies, so the SQL must evaluate to UNKNOWN — never FALSE — when the - * condition column is NULL. The two branch arms alone are not enough: with both branch - * predicates false they evaluate {@code (NULL AND FALSE) OR (NULL AND FALSE) = FALSE}, - * which {@code not(...)} flips to TRUE and leaks rows the PDP denies. The third arm - * ({@link #unknownWhenConditionUnknown}) restores the missing UNKNOWN: it is FALSE for a - * known condition (no effect on the OR) and UNKNOWN for a NULL one, driving the whole OR - * to UNKNOWN so the row is excluded under BOTH polarities. - * - *

The condition is translated fresh for each arm: Hibernate 6 negation is stateful - * (see {@link #negate}), so sharing one Predicate node between the positive and negated - * arms is unsafe. + *

Extension recipe — adding a new comparison-operand type, worked example + * {@code timestamp("2024-01-01T00:00:00Z")} appearing as a comparison operand: + *

    + *
  1. Add a {@code Resolved} case: {@code record Timestamp(Operand arg)} with a lazy + * accessor that parses the argument (its errors are then part of the contract);
  2. + *
  3. Classify it in {@link #resolve}'s EXPRESSION arm (before the {@code Opaque} + * fallback): {@code "timestamp".equals(exprOp) -> new Resolved.Timestamp(...)}; + * a pure-constant argument folds HERE, in the accessor — never in dispatch;
  4. + *
  5. Handle the new pairings in {@link #dispatch} next to the existing typed cases + * (e.g. {@code Field vs Timestamp} → compare the column against the parsed + * instant via {@link #applyLeaf} so {@link OperatorFunction} overrides keep + * working).
  6. + *
+ * Nothing else changes: no new probe, no re-scan, no ordering decision — unmatched + * pairings still fall through to {@link #leafOperandError}, whose "Unexpected + * timestamp() expression in leaf operand of X" message is the pinned behavior today. */ - private Predicate translateTernary(List ifOps, - java.util.function.Function branchTranslator, - Scope scope) { - if (ifOps.size() != 3) { - throw new IllegalArgumentException( - "if (ternary) requires exactly 3 operands (condition, then, else), got " - + ifOps.size()); - } - Operand condition = ifOps.get(0); - Operand thenBranch = ifOps.get(1); - Operand elseBranch = ifOps.get(2); + private final class ComparisonTranslator { - if (condition.getNodeCase() == Operand.NodeCase.VALUE) { - Boolean known = constantBooleanOrNull(condition); - if (known == null) { + /** + * The single entry point for the {@code default} arm of + * {@code traverseExpression}: translate {@code op(operands...)} where {@code op} is + * not one of the structural operators handled by name. The pipeline is fixed by code + * order, not by probe-chain position: + *
    + *
  1. Ternary rewrite on the RAW operands — a {@code cmp(if(...), other)} + * substitutes each branch back into the comparison and recurses, so it must see + * source order before any mirroring;
  2. + *
  3. Normalization to field-first form (mirroring directional operators — + * see {@link NormalizedBinary}); every later stage assumes it;
  4. + *
  5. size() comparisons as a dedicated step: the emptiness shortcuts + * (EXISTS / NOT EXISTS), the COUNT/LENGTH shapes and the tri-state + * {@code size(filter(...))} guard are subquery translations, not operand + * resolutions, and their SQL shapes are pinned by the differential oracle;
  6. + *
  7. Operand resolution — each operand through the single {@link #resolve} + * seam;
  8. + *
  9. Dispatch on the resolved pair ({@link #dispatch}).
  10. + *
+ */ + Predicate translate(String op, List operands, Scope scope) { + Predicate ternaryPred = tryTernaryComparison(op, operands, scope); + if (ternaryPred != null) { + return ternaryPred; + } + NormalizedBinary nb = NormalizedBinary.of(op, operands); + Predicate sizePred = trySizeComparison(nb.op(), nb.operands(), scope); + if (sizePred != null) { + return sizePred; + } + // Every leaf operator is binary. Extra operands are a malformed plan and must + // fail loudly rather than silently dropping one. + if (nb.operands().size() != 2) { throw new IllegalArgumentException( - "if (ternary) condition must be a boolean expression"); + nb.op() + " requires exactly 2 operands, got " + nb.operands().size()); } - return branchTranslator.apply(known ? thenBranch : elseBranch); + return dispatch(nb.op(), + resolve(nb.operands().get(0)), + resolve(nb.operands().get(1)), + nb.operands(), scope); } - return cb.or( - cb.and(traverse(condition, scope), branchTranslator.apply(thenBranch)), - cb.and(negate(traverse(condition, scope)), branchTranslator.apply(elseBranch)), - unknownWhenConditionUnknown(condition, scope)); - } - - /** - * UNKNOWN exactly when {@code condition} is UNKNOWN, FALSE when it is known: - * {@code NOT(c OR NOT c)}. Truth table: condition TRUE → {@code NOT(TRUE OR FALSE)} = - * FALSE; condition FALSE → {@code NOT(FALSE OR TRUE)} = FALSE; condition UNKNOWN → - * {@code NOT(UNKNOWN OR UNKNOWN)} = UNKNOWN. As the last arm of the ternary OR it - * therefore vanishes for known conditions and forces the whole predicate to UNKNOWN for - * NULL-derived ones — matching the CEL evaluation error (deny) under both polarities. - * The condition is translated fresh for each occurrence (Hibernate 6 negation is - * stateful — see {@link #negate}). - */ - private Predicate unknownWhenConditionUnknown(Operand condition, Scope scope) { - return negate(cb.or(traverse(condition, scope), negate(traverse(condition, scope)))); - } + // -- if (CEL ternary) -- - /** - * A CEL ternary in boolean position — {@code if(c, a, b)} used directly as a condition, - * so both branches are themselves boolean and translate through - * {@link #booleanBranchPredicate}. Same rewrite, rationale and null semantics as - * {@link #translateTernary}. - */ - private Predicate handleBareTernary(List operands, Scope scope) { - return translateTernary(operands, branch -> booleanBranchPredicate(branch, scope), scope); - } + /** + * The orderable/equality comparison operators (eq/ne/lt/gt/le/ge) — shared by the + * ternary rewrite, the arithmetic path, and the constant-vs-constant fold. + */ + private static final Set COMPARISON_OPS = + Set.of("eq", "ne", "lt", "gt", "le", "ge"); - /** - * A ternary branch in boolean position: a boolean VALUE folds to the always-true / - * always-false predicate (the same collapse the unsolvable add-solve cases use); anything - * else translates as a normal boolean operand (bare variables become {@code path = true}). - */ - private Predicate booleanBranchPredicate(Operand branch, Scope scope) { - if (branch.getNodeCase() == Operand.NodeCase.VALUE) { - Boolean constant = constantBooleanOrNull(branch); - if (constant == null) { - throw new IllegalArgumentException( - "if (ternary) branch in boolean position must be a boolean"); + /** + * A comparison wrapping a CEL ternary — {@code cmp(if(c, a, b), other)}. Each branch is + * substituted back into the comparison and recursed through {@link #traverseExpression}, + * so a ternary branch behaves identically to the same comparison written directly (see + * {@link #translateTernary} for the rewrite and its null semantics). Recursion also + * handles nested ternaries and a ternary on the other side for free. + * + * @return the rewritten predicate, or {@code null} if this comparison involves no ternary + */ + private Predicate tryTernaryComparison(String op, List operands, Scope scope) { + if (!COMPARISON_OPS.contains(op) || operands.size() != 2) { + return null; } - return constant ? cb.conjunction() : cb.disjunction(); + int idx; + if (isIfExpression(operands.get(0))) { + idx = 0; + } else if (isIfExpression(operands.get(1))) { + idx = 1; + } else { + return null; + } + List ifOps = operands.get(idx).getExpression().getOperandsList(); + return translateTernary(ifOps, + branch -> traverseExpression(substituteOperand(op, operands, idx, branch), scope), + scope); } - return traverse(branch, scope); - } - /** Rebuild {@code op(operands...)} with the operand at {@code idx} replaced. */ - private static PlanResourcesFilter.Expression substituteOperand( - String op, List operands, int idx, Operand replacement) { - PlanResourcesFilter.Expression.Builder b = - PlanResourcesFilter.Expression.newBuilder().setOperator(op); - for (int i = 0; i < operands.size(); i++) { - b.addOperands(i == idx ? replacement : operands.get(i)); + private static boolean isIfExpression(Operand o) { + return o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "if".equals(o.getExpression().getOperator()); } - return b.build(); - } - /** The operand's boolean constant, or {@code null} if it is not a boolean VALUE. */ - private static Boolean constantBooleanOrNull(Operand o) { - return PlanValues.protoValueToJava(o.getValue()) instanceof Boolean b ? b : null; - } - - // -- Leaf operators (eq/ne/lt/gt/le/ge/contains/startsWith/endsWith) -- + /** + * Rewrite a CEL ternary {@code if(c, a, b)} into a pure predicate: + * + *
{@code (pred(c) AND branch(a)) OR (NOT pred(c) AND branch(b)) OR NOT(pred(c) OR NOT pred(c))}
+ * + * where {@code branch} is supplied by the caller — comparison substitution for + * {@link #tryTernaryComparison}, {@link #booleanBranchPredicate} for + * {@link #handleBareTernary}. We rewrite instead of emitting {@code CASE WHEN} + * ({@code cb.selectCase}) because this translator is predicate-only: every existing typed + * leaf path — field-first normalization, size() handling, add-fold, fractional + * double-space comparison — operates on comparison predicates, and routing the branches + * back through those exact paths keeps them identical to the same condition written + * directly. + * + *

A constant boolean condition folds to a single branch — only that branch is + * translated, so an untranslatable dead branch cannot fail the whole plan. + * + *

Null semantics and the third (condition-UNKNOWN) arm are owned by + * {@link TriPredicate#ternary}: a null/missing condition in a CEL ternary is an + * evaluation error and the check denies, so the SQL must evaluate to UNKNOWN — never + * FALSE — when the condition column is NULL. The condition is passed as a Supplier and + * translated fresh for each arm (Hibernate 6 negation is stateful — see + * {@link TriPredicate#not}). + */ + private Predicate translateTernary(List ifOps, + java.util.function.Function branchTranslator, + Scope scope) { + if (ifOps.size() != 3) { + throw new IllegalArgumentException( + "if (ternary) requires exactly 3 operands (condition, then, else), got " + + ifOps.size()); + } + Operand condition = ifOps.get(0); + Operand thenBranch = ifOps.get(1); + Operand elseBranch = ifOps.get(2); - /** The receiver-sensitive CEL string-match methods (see {@link NormalizedBinary}). */ - private static final Set STRING_MATCH_OPS = - Set.of("contains", "startsWith", "endsWith"); + if (condition.getNodeCase() == Operand.NodeCase.VALUE) { + Boolean known = constantBooleanOrNull(condition); + if (known == null) { + throw new IllegalArgumentException( + "if (ternary) condition must be a boolean expression"); + } + return branchTranslator.apply(known ? thenBranch : elseBranch); + } - /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ - private Predicate handleLeafOperator(String op, List operands, Scope scope) { - // Every leaf operator is binary. Extra operands are a malformed plan and must fail - // loudly: the collector below would otherwise silently DROP one. - if (operands.size() != 2) { - throw new IllegalArgumentException( - op + " requires exactly 2 operands, got " + operands.size()); + return tri.ternary( + () -> traverse(condition, scope), + () -> branchTranslator.apply(thenBranch), + () -> branchTranslator.apply(elseBranch)); } - // Constant-vs-constant comparisons are statically evaluated. The planner never emits - // them directly, but ternary substitution produces them — the else branch of - // `(aBool ? aNumber : 0) > 0` becomes gt(value(0), value(0)). - if (COMPARISON_OPS.contains(op) - && operands.get(0).getNodeCase() == Operand.NodeCase.VALUE - && operands.get(1).getNodeCase() == Operand.NodeCase.VALUE) { - return constantComparison(op, - PlanValues.protoValueToJava(operands.get(0).getValue()), - PlanValues.protoValueToJava(operands.get(1).getValue())); + /** + * A CEL ternary in boolean position — {@code if(c, a, b)} used directly as a condition, + * so both branches are themselves boolean and translate through + * {@link #booleanBranchPredicate}. Same rewrite, rationale and null semantics as + * {@link #translateTernary}. + */ + private Predicate handleBareTernary(List operands, Scope scope) { + return translateTernary(operands, branch -> booleanBranchPredicate(branch, scope), scope); } - // Constant-receiver string matches: `"a,b".contains(R.attr.x)` arrives as - // contains(value, variable) — the CONSTANT is the haystack and the COLUMN the - // needle (NormalizedBinary deliberately leaves these in source order). This must - // be checked BEFORE the add-detection below so an unfolded concat receiver - // (`("a" + "b").contains(R.attr.x)`) routes here too, not into the add-solve path - // (which would fold the constant and translate the INVERTED column-haystack LIKE). - if (STRING_MATCH_OPS.contains(op) - && operands.get(1).getNodeCase() == Operand.NodeCase.VARIABLE) { - Object receiver = constantReceiverOrNull(operands.get(0)); - if (receiver != null) { - if (!(receiver instanceof String haystack)) { + /** + * A ternary branch in boolean position: a boolean VALUE folds to the always-true / + * always-false predicate (the same collapse the unsolvable add-solve cases use); anything + * else translates as a normal boolean operand (bare variables become {@code path = true}). + */ + private Predicate booleanBranchPredicate(Operand branch, Scope scope) { + if (branch.getNodeCase() == Operand.NodeCase.VALUE) { + Boolean constant = constantBooleanOrNull(branch); + if (constant == null) { throw new IllegalArgumentException( - op + " requires a string receiver, got " + typeName(receiver)); + "if (ternary) branch in boolean position must be a boolean"); } - Path needle = scope.resolvePath(operands.get(1).getVariable()); - // The needle is a column, so it is escaped dynamically; a NULL needle is - // a missing attribute → CEL error → deny (fieldToFieldLike guards it). - return switch (op) { - case "contains" -> fieldToFieldLike(cb.literal(haystack), needle, true, true); - case "startsWith" -> fieldToFieldLike(cb.literal(haystack), needle, false, true); - case "endsWith" -> fieldToFieldLike(cb.literal(haystack), needle, true, false); - default -> throw new IllegalArgumentException( - "Unsupported string-match operator: " + op); - }; + return constant ? cb.conjunction() : cb.disjunction(); } + return traverse(branch, scope); } - // Detect leaf comparisons where one side is an 'add' expression (e.g. string - // concatenation: `aString == "prefix:" + R.attr.id`). We fold constants and solve for - // the field side when possible — same algorithm as the Prisma adapter. - Operand addExprOperand = null; - Operand otherOperand = null; - for (Operand o : operands) { - if (o.getNodeCase() == Operand.NodeCase.EXPRESSION - && "add".equals(o.getExpression().getOperator())) { - addExprOperand = o; - } else { - otherOperand = o; + /** Rebuild {@code op(operands...)} with the operand at {@code idx} replaced. */ + private static PlanResourcesFilter.Expression substituteOperand( + String op, List operands, int idx, Operand replacement) { + PlanResourcesFilter.Expression.Builder b = + PlanResourcesFilter.Expression.newBuilder().setOperator(op); + for (int i = 0; i < operands.size(); i++) { + b.addOperands(i == idx ? replacement : operands.get(i)); } + return b.build(); } - if (addExprOperand != null) { - if (otherOperand == null) { - throw new IllegalArgumentException("add comparison requires a second operand"); - } - return handleAddComparison(op, addExprOperand.getExpression(), otherOperand, scope); + + /** The operand's boolean constant, or {@code null} if it is not a boolean VALUE. */ + private static Boolean constantBooleanOrNull(Operand o) { + return PlanValues.protoValueToJava(o.getValue()) instanceof Boolean b ? b : null; } - String variable = null; - String secondVariable = null; - Object value = null; - boolean valueSeen = false; - for (Operand o : operands) { - switch (o.getNodeCase()) { - case VARIABLE -> { - if (variable != null) { - secondVariable = o.getVariable(); - } else { - variable = o.getVariable(); - } + // -- the operand-resolution seam -- + + /** The receiver-sensitive CEL string-match methods (see {@link NormalizedBinary}). */ + private static final Set STRING_MATCH_OPS = + Set.of("contains", "startsWith", "endsWith"); + + /** + * A comparison operand resolved to its translation-relevant shape — the single seam + * every leaf comparison goes through ({@link #resolve}). Resolution is purely + * structural: values convert and constants fold LAZILY (at the dispatch site that + * consumes them), because WHICH error a malformed operand raises depends on the shape + * of the whole comparison — e.g. a non-numeric constant inside {@code add} is a + * type-mismatch when solved against a field but an + * "Arithmetic comparison requires numeric operands" when lowered to SQL arithmetic — + * and eager conversion here would re-order those pinned messages. + */ + private sealed interface Resolved { + /** A plan constant (raw VALUE node); {@link #value()} converts on demand. */ + record Constant(Operand operand) implements Resolved { + Object value() { + return PlanValues.protoValueToJava(operand.getValue()); } - case VALUE -> { - value = PlanValues.protoValueToJava(o.getValue()); - valueSeen = true; + } + + /** A mapped column reference; the path resolves at the consuming dispatch site. */ + record Field(String variable) implements Resolved {} + + /** + * {@code add(value, value)} — a pure-constant subtree. {@link #fold()} folds it + * with {@link PlanValues#foldAdd} (strings concatenate, numbers add), so by the + * time the resolved pair is dispatched no "who owns the fold" question exists. + */ + record ConstantAdd(Operand left, Operand right) implements Resolved { + Object fold() { + return PlanValues.foldAdd( + PlanValues.protoValueToJava(left.getValue()), + PlanValues.protoValueToJava(right.getValue())); } + } + + /** + * {@code add(field, value)} / {@code add(value, field)} — solvable for the field + * under eq/ne against a constant ({@link PlanValues#solveAdd}); every other + * pairing lowers to SQL arithmetic. + */ + record FieldPlusConstant(String fieldVariable, Operand constant, boolean fieldIsLeft) + implements Resolved {} + + /** + * Any other arithmetic-rooted expression ({@code sub}/{@code mult}/{@code div}/ + * {@code mod}, or {@code add} in a shape with nested expressions or wrong arity) — + * lowered to double-space SQL by {@link #resolveNumericOperand}. + */ + record Arithmetic(String operator) implements Resolved {} + + /** + * An operand no leaf comparison understands ({@code timestamp()}, {@code map()}, + * {@code lambda}, an unset node...). Dispatch routes these to + * {@link #leafOperandError}, which reports from the RAW operands so each shape + * keeps its exact message. + */ + record Opaque() implements Resolved {} + } + + /** + * THE operand-resolution seam: classify one comparison operand. Adding a new operand + * type starts here — see the extension recipe on {@link ComparisonTranslator}. + */ + private Resolved resolve(Operand o) { + return switch (o.getNodeCase()) { + case VALUE -> new Resolved.Constant(o); + case VARIABLE -> new Resolved.Field(o.getVariable()); case EXPRESSION -> { - // H3: map() compositions are only accepted inside hasIntersection. - // A direct comparison like eq(map(...), [...]) reaches here; point users - // at the supported shape rather than throwing a generic operand error. - String innerOp = o.getExpression().getOperator(); - if ("map".equals(innerOp)) { - throw new IllegalArgumentException( - "Direct comparison of map(...) to a value is not supported " - + "(operator: " + op + "). Wrap the map() expression in " - + "hasIntersection(map(...), [...]) instead."); + PlanResourcesFilter.Expression e = o.getExpression(); + String exprOp = e.getOperator(); + if (!ARITHMETIC_OPS.contains(exprOp)) { + yield new Resolved.Opaque(); } - throw new IllegalArgumentException( - "Unexpected " + innerOp + "() expression in leaf operand of " + op); + if ("add".equals(exprOp) && e.getOperandsCount() == 2) { + Operand l = e.getOperands(0); + Operand r = e.getOperands(1); + boolean lValue = l.getNodeCase() == Operand.NodeCase.VALUE; + boolean rValue = r.getNodeCase() == Operand.NodeCase.VALUE; + if (lValue && rValue) { + yield new Resolved.ConstantAdd(l, r); + } + if (l.getNodeCase() == Operand.NodeCase.VARIABLE && rValue) { + yield new Resolved.FieldPlusConstant(l.getVariable(), r, true); + } + if (lValue && r.getNodeCase() == Operand.NodeCase.VARIABLE) { + yield new Resolved.FieldPlusConstant(r.getVariable(), l, false); + } + } + yield new Resolved.Arithmetic(exprOp); } - default -> throw new IllegalArgumentException( - "Unexpected operand type in leaf expression: " + o.getNodeCase()); - } - } - if (variable == null) { - throw new IllegalArgumentException("Missing variable operand for " + op); - } - if (secondVariable != null) { - return fieldToFieldComparison(op, variable, secondVariable, scope); - } - if (!valueSeen) { - throw new IllegalArgumentException("Missing value operand for " + op); + default -> new Resolved.Opaque(); + }; } - Path path = scope.resolvePath(variable); + /** Whether this operand resolved to an {@code add}-rooted expression (any shape). */ + private static boolean isAddRooted(Resolved r) { + return r instanceof Resolved.ConstantAdd + || r instanceof Resolved.FieldPlusConstant + || (r instanceof Resolved.Arithmetic a && "add".equals(a.operator())); + } - if (value == null) { - // A registered override owns the operator's full translation, including a null RHS. - return withOverride(op, path, null, () -> switch (op) { - case "eq" -> cb.isNull(path); - case "ne" -> cb.isNotNull(path); - default -> throw new IllegalArgumentException( - "Null values are only supported with eq and ne operators (got " + op + ")"); - }); + /** Whether this operand resolved to any arithmetic-rooted expression. */ + private static boolean isArithmeticRooted(Resolved r) { + return r instanceof Resolved.ConstantAdd + || r instanceof Resolved.FieldPlusConstant + || r instanceof Resolved.Arithmetic; } - return applyLeaf(op, path, value); - } + // -- dispatch on the resolved pair -- - /** - * Statically evaluate a comparison between two plan constants and collapse it to an - * always-true ({@code 1=1}) or always-false ({@code 1=0}) predicate — the same collapse - * the unsolvable {@code add}-solve cases use. Numbers compare in double space: protobuf - * {@code Value.getNumberValue()} is a double, and {@link PlanValues#protoValueToJava} - * only splits Long/Double for whole-number cosmetics, not semantics. Strings compare - * lexicographically; booleans (and mixed incomparable types) support eq/ne only — - * eq → false, ne → true — while ordering them is a planner bug and throws. - */ - private Predicate constantComparison(String op, Object left, Object right) { - boolean result; - if ("eq".equals(op) || "ne".equals(op)) { - boolean equal = (left instanceof Number ln && right instanceof Number rn) - ? ln.doubleValue() == rn.doubleValue() - : Objects.equals(left, right); - result = "eq".equals(op) == equal; - } else { - int cmp; - if (left instanceof Number ln && right instanceof Number rn) { - cmp = Double.compare(ln.doubleValue(), rn.doubleValue()); - } else if (left instanceof String ls && right instanceof String rs) { - cmp = ls.compareTo(rs); - } else { - throw new IllegalArgumentException( - "Cannot order constant operands of " + op + ": " - + typeName(left) + " vs " + typeName(right)); + /** + * Translate one leaf comparison from its resolved operand pair. Cases are ordered by + * code structure, top to bottom; {@code operands} is the (normalized) raw operand list, + * kept only for the paths that must see raw shapes — SQL arithmetic lowering + * ({@link #resolveNumericOperand} walks subtrees) and error reporting + * ({@link #leafOperandError} pins per-shape messages). + */ + private Predicate dispatch(String op, Resolved left, Resolved right, + List operands, Scope scope) { + // Constant-vs-constant comparisons are statically evaluated. The planner never emits + // them directly, but ternary substitution produces them — the else branch of + // `(aBool ? aNumber : 0) > 0` becomes gt(value(0), value(0)). + if (COMPARISON_OPS.contains(op) + && left instanceof Resolved.Constant lc + && right instanceof Resolved.Constant rc) { + return constantComparison(op, lc.value(), rc.value()); } - result = switch (op) { - case "lt" -> cmp < 0; - case "gt" -> cmp > 0; - case "le" -> cmp <= 0; - case "ge" -> cmp >= 0; - default -> throw new IllegalArgumentException( - "Unsupported constant comparison operator: " + op); - }; - } - return result ? cb.conjunction() : cb.disjunction(); - } - private static String typeName(Object o) { - return o == null ? "null" : o.getClass().getSimpleName(); - } + // Constant-receiver string matches: `"a,b".contains(R.attr.x)` arrives as + // contains(value, variable) — the CONSTANT is the haystack and the COLUMN the + // needle (NormalizedBinary deliberately leaves these in source order). An unfolded + // concat receiver (`("a" + "b").contains(R.attr.x)`) folds here too — NOT into the + // add-solve path, which would translate the INVERTED column-haystack LIKE. + if (STRING_MATCH_OPS.contains(op) && right instanceof Resolved.Field needleField) { + Object receiver = left instanceof Resolved.Constant c ? c.value() + : left instanceof Resolved.ConstantAdd ca ? ca.fold() + : null; + if (receiver != null) { + if (!(receiver instanceof String haystack)) { + throw new IllegalArgumentException( + op + " requires a string receiver, got " + typeName(receiver)); + } + Path needle = scope.resolvePath(needleField.variable()); + // The needle is a column, so it is escaped dynamically; a NULL needle is + // a missing attribute → CEL error → deny (fieldToFieldLike guards it). + return switch (op) { + case "contains" -> fieldToFieldLike(cb.literal(haystack), needle, true, true); + case "startsWith" -> fieldToFieldLike(cb.literal(haystack), needle, false, true); + case "endsWith" -> fieldToFieldLike(cb.literal(haystack), needle, true, false); + default -> throw new IllegalArgumentException( + "Unsupported string-match operator: " + op); + }; + } + // A NULL receiver constant is not a haystack; fall through so the null-RHS + // leaf branch below owns the error message. + } - /** - * The constant value of a string-match RECEIVER operand: a plain VALUE, or an - * {@code add(value, value)} concatenation folded to its constant. Returns {@code null} - * when the operand is not a constant (e.g. a variable or a field-bearing expression), - * in which case the caller falls through to the ordinary leaf paths. - */ - private static Object constantReceiverOrNull(Operand o) { - return switch (o.getNodeCase()) { - case VALUE -> PlanValues.protoValueToJava(o.getValue()); - case EXPRESSION -> { - PlanResourcesFilter.Expression e = o.getExpression(); - if ("add".equals(e.getOperator()) - && e.getOperandsCount() == 2 - && e.getOperands(0).getNodeCase() == Operand.NodeCase.VALUE - && e.getOperands(1).getNodeCase() == Operand.NodeCase.VALUE) { - yield PlanValues.foldAdd( - PlanValues.protoValueToJava(e.getOperands(0).getValue()), - PlanValues.protoValueToJava(e.getOperands(1).getValue())); + if (COMPARISON_OPS.contains(op)) { + // Fold: `field op add(value, value)` — the folded constant compares like any + // plan constant (normalization guarantees the field arrives first). Strings + // concatenate here, matching CEL — this shape never enters double space. + if (left instanceof Resolved.Field f && right instanceof Resolved.ConstantAdd ca) { + return applyLeaf(op, scope.resolvePath(f.variable()), ca.fold()); + } + // Solve: `add(field, const) eq/ne constant` — string concat/numeric solve for + // the field side (same algorithm as the Prisma adapter). + if (("eq".equals(op) || "ne".equals(op)) + && left instanceof Resolved.FieldPlusConstant fpc + && right instanceof Resolved.Constant other) { + return solveAddComparison(op, fpc, other, scope); } - yield null; + // Everything else arithmetic-rooted lowers to SQL-side double-space arithmetic. + if (isArithmeticRooted(left) || isArithmeticRooted(right)) { + return numericComparison(op, operands, scope); + } + } else if (isAddRooted(left) || isAddRooted(right)) { + // add under a non-comparison operator (string matches, unknown operators): + // only the constant fold against a field translates; everything else reports + // the add-specific shape errors. + return addFoldOrError(op, operands, scope); } - default -> null; - }; - } - /** - * Compare two mapped columns directly (eq/ne/lt/gt/le/ge) or pattern-match one column - * against another (contains/startsWith/endsWith). Operand source order is preserved — - * two variables rank equally, so {@link NormalizedBinary} never swaps them. - */ - private Predicate fieldToFieldComparison(String op, String leftVar, String rightVar, - Scope scope) { - jakarta.persistence.criteria.Expression left = scope.resolvePath(leftVar); - jakarta.persistence.criteria.Expression right = scope.resolvePath(rightVar); - return switch (op) { - case "eq", "ne", "lt", "gt", "le", "ge" -> comparePredicate(op, left, right); - case "contains" -> fieldToFieldLike(left, right, true, true); - case "startsWith" -> fieldToFieldLike(left, right, false, true); - case "endsWith" -> fieldToFieldLike(left, right, true, false); - default -> throw new IllegalArgumentException( - "Field-to-field comparison is not supported for operator '" + op + "': " - + leftVar + " vs " + rightVar); - }; - } + if (left instanceof Resolved.Field a && right instanceof Resolved.Field b) { + return fieldToFieldComparison(op, a.variable(), b.variable(), scope); + } - /** - * Raw-typed comparison of two SQL expressions — the shared dispatch of field-to-field - * comparisons and arithmetic expression-vs-expression comparisons. Constant-RHS shapes - * do NOT route here: they bind through the plain-value overloads on purpose (double - * bind parameters — see {@link #tryArithmeticComparison}). - */ - @SuppressWarnings({"rawtypes", "unchecked"}) - private Predicate comparePredicate(String op, - jakarta.persistence.criteria.Expression left, - jakarta.persistence.criteria.Expression right) { - return switch (op) { - case "eq" -> cb.equal(left, right); - case "ne" -> cb.notEqual(left, right); - case "lt" -> cb.lessThan(left, right); - case "gt" -> cb.greaterThan(left, right); - case "le" -> cb.lessThanOrEqualTo(left, right); - case "ge" -> cb.greaterThanOrEqualTo(left, right); - default -> throw new IllegalArgumentException( - "Unsupported arithmetic comparison operator: " + op); - }; - } + // The ordinary scalar leaf: one mapped column against one plan constant. Order- + // insensitive on purpose — receiver-sensitive operators are never normalized, so a + // null receiver arrives value-first and must still reach the null-RHS message. + Resolved.Field field = left instanceof Resolved.Field lf ? lf + : right instanceof Resolved.Field rf ? rf : null; + Resolved.Constant constant = left instanceof Resolved.Constant lc2 ? lc2 + : right instanceof Resolved.Constant rc2 ? rc2 : null; + if (field != null && constant != null) { + return leafFieldValue(op, field, constant, scope); + } - /** - * {@code haystackColumn LIKE wildcards(escape(needleColumn))} — the column-to-column - * analogue of the constant LIKE path in {@link #defaultLeaf}. The needle is data, so its - * LIKE metacharacters are escaped dynamically with nested {@code REPLACE} (portable: - * H2/Postgres/MySQL/Oracle/SQL Server): {@code \} first, then {@code %} and {@code _}, - * mirroring {@link PlanValues#escapeLike} and the same explicit {@code '\'} escape char. - * - *

The explicit {@code IS NOT NULL} guard on the needle matches CEL (a missing - * attribute is an evaluation error → deny) and also defends against dialects whose - * {@code CONCAT} treats NULL as {@code ''}, which would otherwise turn a NULL needle - * into a match-anything {@code '%%'} pattern. - */ - private Predicate fieldToFieldLike(jakarta.persistence.criteria.Expression haystack, - jakarta.persistence.criteria.Expression needle, - boolean leadingWildcard, boolean trailingWildcard) { - jakarta.persistence.criteria.Expression escaped = - needle.as(String.class); - escaped = cb.function("replace", String.class, - escaped, cb.literal("\\"), cb.literal("\\\\")); - escaped = cb.function("replace", String.class, - escaped, cb.literal("%"), cb.literal("\\%")); - escaped = cb.function("replace", String.class, - escaped, cb.literal("_"), cb.literal("\\_")); - jakarta.persistence.criteria.Expression pattern = escaped; - if (leadingWildcard) { - pattern = cb.concat(cb.literal("%"), pattern); - } - if (trailingWildcard) { - pattern = cb.concat(pattern, cb.literal("%")); + throw leafOperandError(op, operands); } - return cb.and( - cb.isNotNull(needle), - cb.like(haystack.as(String.class), pattern, '\\')); - } - /** - * Apply a scalar leaf operator, consulting the per-operator {@code overrides} hook first so a - * registered {@link OperatorFunction} wins on EVERY path that produces this operator — direct - * comparison, {@code add}-folded comparison, and bare-boolean — not just the direct one. - */ - private Predicate applyLeaf(String op, Path path, Object value) { - return withOverride(op, path, value, () -> defaultLeaf(op, path, value)); - } + /** `field op value` (or value-first for non-normalized operators): the scalar leaf. */ + private Predicate leafFieldValue(String op, Resolved.Field field, + Resolved.Constant constant, Scope scope) { + Object value = constant.value(); + Path path = scope.resolvePath(field.variable()); - /** - * Route a scalar (field, value) translation through the per-operator {@code overrides} - * hook: a registered {@link OperatorFunction} owns the operator's full translation - * (mirrored operators are consulted under the mirrored name — see - * {@link NormalizedBinary}); otherwise the supplied default applies. - */ - private Predicate withOverride(String op, jakarta.persistence.criteria.Expression field, - Object value, Supplier dflt) { - OperatorFunction override = overrides.get(op); - if (override != null) { - return override.apply(cb, field, value); + if (value == null) { + // A registered override owns the operator's full translation, including a null RHS. + return withOverride(op, path, null, () -> switch (op) { + case "eq" -> cb.isNull(path); + case "ne" -> cb.isNotNull(path); + default -> throw new IllegalArgumentException( + "Null values are only supported with eq and ne operators (got " + op + ")"); + }); + } + + return applyLeaf(op, path, value); } - return dflt.get(); - } - @SuppressWarnings({"rawtypes", "unchecked"}) - private Predicate defaultLeaf(String op, Path path, Object value) { - // Fractional constants compare in double space: protoValueToJava yields Double only - // for non-whole numbers, and Hibernate refuses to coerce e.g. 1.5 into an - // Integer-typed path ("not a whole number") — but `intColumn >= 1.5` is legal CEL - // that the planner emits verbatim. - jakarta.persistence.criteria.Expression raw = - (value instanceof Double) ? path.as(Double.class) : path; - return switch (op) { - case "eq" -> cb.equal(raw, value); - case "ne" -> cb.notEqual(raw, value); - case "lt" -> cb.lessThan(raw, (Comparable) value); - case "gt" -> cb.greaterThan(raw, (Comparable) value); - case "le" -> cb.lessThanOrEqualTo(raw, (Comparable) value); - case "ge" -> cb.greaterThanOrEqualTo(raw, (Comparable) value); - case "contains" -> cb.like(path.as(String.class), - "%" + PlanValues.escapeLike(String.valueOf(value)) + "%", '\\'); - case "startsWith" -> cb.like(path.as(String.class), - PlanValues.escapeLike(String.valueOf(value)) + "%", '\\'); - case "endsWith" -> cb.like(path.as(String.class), - "%" + PlanValues.escapeLike(String.valueOf(value)), '\\'); - default -> throw new IllegalArgumentException("Unsupported operator: " + op); - }; - } + /** + * Solve {@code add(field, const) eq/ne constant} for the field. When no solution + * exists (e.g. {@code "projects:123" == "users:" + R.id} can never be true), eq is + * always-false; ne is NOT always-true — a missing attribute makes the concatenation a + * CEL evaluation error ({@code "users:" + null}) → deny, so NULL rows must stay + * excluded: IS NOT NULL, never an unconditional {@code 1=1} (which would leak exactly + * the rows the PDP denies). + */ + private Predicate solveAddComparison(String op, Resolved.FieldPlusConstant fpc, + Resolved.Constant other, Scope scope) { + Object otherValue = other.value(); + Object addConst = PlanValues.protoValueToJava(fpc.constant().getValue()); + Object solved = PlanValues.solveAdd(otherValue, addConst, fpc.fieldIsLeft()); + if (solved == null) { + if ("eq".equals(op)) { + return cb.disjunction(); + } + return cb.isNotNull(scope.resolvePath(fpc.fieldVariable())); + } + return applyLeaf(op, scope.resolvePath(fpc.fieldVariable()), solved); + } - // -- arithmetic (add/sub/mult/div) as a comparison operand -- + /** + * {@code add} under a non-comparison operator. The only translatable shape is the + * constant fold against a field ({@code ("a" + "b") op field} with the fold as the + * VALUE side); the rest report the add-specific shape errors, matching the raw + * operand layout (either side may hold the {@code add}). + */ + private Predicate addFoldOrError(String op, List operands, Scope scope) { + Operand addExprOperand = null; + Operand otherOperand = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "add".equals(o.getExpression().getOperator())) { + addExprOperand = o; + } else { + otherOperand = o; + } + } + if (otherOperand == null) { + throw new IllegalArgumentException("add comparison requires a second operand"); + } + List addOperands = addExprOperand.getExpression().getOperandsList(); + if (addOperands.size() != 2) { + throw new IllegalArgumentException("add requires exactly 2 operands"); + } + Operand addLeft = addOperands.get(0); + Operand addRight = addOperands.get(1); + if (addLeft.getNodeCase() == Operand.NodeCase.VALUE + && addRight.getNodeCase() == Operand.NodeCase.VALUE) { + Object folded = PlanValues.foldAdd( + PlanValues.protoValueToJava(addLeft.getValue()), + PlanValues.protoValueToJava(addRight.getValue())); + if (otherOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException( + "add(const, const) compared to a non-field operand is not supported"); + } + return applyLeaf(op, scope.resolvePath(otherOperand.getVariable()), folded); + } + throw new IllegalArgumentException( + "add comparison with a field reference only supports eq/ne (got " + op + ")"); + } - /** CEL arithmetic operators that can appear as an operand of a comparison. */ - private static final Set ARITHMETIC_OPS = Set.of("add", "sub", "mult", "div", "mod"); + /** + * Report an operand shape no leaf case accepts. Reads the RAW operands in order so + * each malformed shape keeps its exact message: {@code map()} points at the supported + * {@code hasIntersection} wrapping, other expressions name themselves, unset nodes + * report their node case, and an all-constant pair reports the missing variable. + */ + private IllegalArgumentException leafOperandError(String op, List operands) { + String variable = null; + for (Operand o : operands) { + switch (o.getNodeCase()) { + case VARIABLE -> variable = o.getVariable(); + // Conversion can itself reject a malformed VALUE — same order as reading + // the operands left to right. + case VALUE -> PlanValues.protoValueToJava(o.getValue()); + case EXPRESSION -> { + // H3: map() compositions are only accepted inside hasIntersection. + // A direct comparison like eq(map(...), [...]) reaches here; point users + // at the supported shape rather than throwing a generic operand error. + String innerOp = o.getExpression().getOperator(); + if ("map".equals(innerOp)) { + throw new IllegalArgumentException( + "Direct comparison of map(...) to a value is not supported " + + "(operator: " + op + "). Wrap the map() expression in " + + "hasIntersection(map(...), [...]) instead."); + } + throw new IllegalArgumentException( + "Unexpected " + innerOp + "() expression in leaf operand of " + op); + } + default -> throw new IllegalArgumentException( + "Unexpected operand type in leaf expression: " + o.getNodeCase()); + } + } + if (variable == null) { + return new IllegalArgumentException("Missing variable operand for " + op); + } + return new IllegalArgumentException("Missing value operand for " + op); + } - /** - * Translate {@code cmp(arith(...), other)} — e.g. {@code R.attr.aNumber + 1.0 > 2.0} - * arriving as {@code gt(add(variable, value(1)), value(2))} — by emitting the arithmetic - * on the SQL side ({@code cb.sum}/{@code diff}/{@code prod}/{@code quot}) and comparing. - * - *

Everything is computed and compared in DOUBLE space. This is not a convenience: - * Cerbos attribute values are protobuf {@code Value} numbers, i.e. ALWAYS CEL doubles at - * check time, so the only arithmetic that can evaluate without a no-overload error is - * double-typed — verified against a live PDP: {@code R.attr.n + 1} (int literal) denies - * every row, {@code + 1.0} works, and {@code / 2.0} is true double division - * ({@code 5 / 2.0 == 2.5}). Integer truncation is therefore never observable through the - * check API, and the wire plan erases the int/double distinction anyway (both arrive as - * {@code number_value}). Emitting the arithmetic (rather than solving algebraically) - * also means multiplication/division by negative constants needs no inequality flipping. - * - *

DOUBLE space must be enforced explicitly, because DB decimal arithmetic is not - * IEEE double arithmetic (see {@link #resolveNumericOperand}): columns are CAST, plan - * constants are folded in Java or bound as double parameters, and pure-constant - * comparisons are evaluated statically in Java (full CEL fidelity, Infinity/NaN - * included). - * - *

{@code mod} stays unsupported: CEL {@code %} has no double overload, so on - * attribute values it always errors (deny) — translating it to SQL {@code MOD} would - * fabricate rows the PDP denies. - * - *

{@link OperatorFunction} overrides win here like on every other scalar path when - * the comparison has a plan constant on one side: the arithmetic SQL expression is - * passed as the field argument and the folded constant (always a {@link Double} — the - * arithmetic path is double-space end to end) as the value. Expression-vs-expression - * comparisons (arithmetic against arithmetic or against another column) have no - * (field, value) pair and are not consulted — the same exclusion as field-to-field - * comparisons. - * - * @return the predicate, or {@code null} if this comparison involves no arithmetic - * expression or the shape is owned by the {@code add} fold/solve path (which - * also handles string concatenation and the override hooks) - */ - private Predicate tryArithmeticComparison(String op, List operands, Scope scope) { - if (!COMPARISON_OPS.contains(op) || operands.size() != 2) { - return null; + /** + * Statically evaluate a comparison between two plan constants and collapse it to an + * always-true ({@code 1=1}) or always-false ({@code 1=0}) predicate — the same collapse + * the unsolvable {@code add}-solve cases use. Numbers compare in double space: protobuf + * {@code Value.getNumberValue()} is a double, and {@link PlanValues#protoValueToJava} + * only splits Long/Double for whole-number cosmetics, not semantics. Strings compare + * lexicographically; booleans (and mixed incomparable types) support eq/ne only — + * eq → false, ne → true — while ordering them is a planner bug and throws. + */ + private Predicate constantComparison(String op, Object left, Object right) { + boolean result; + if ("eq".equals(op) || "ne".equals(op)) { + boolean equal = (left instanceof Number ln && right instanceof Number rn) + ? ln.doubleValue() == rn.doubleValue() + : Objects.equals(left, right); + result = "eq".equals(op) == equal; + } else { + int cmp; + if (left instanceof Number ln && right instanceof Number rn) { + cmp = Double.compare(ln.doubleValue(), rn.doubleValue()); + } else if (left instanceof String ls && right instanceof String rs) { + cmp = ls.compareTo(rs); + } else { + throw new IllegalArgumentException( + "Cannot order constant operands of " + op + ": " + + typeName(left) + " vs " + typeName(right)); + } + result = switch (op) { + case "lt" -> cmp < 0; + case "gt" -> cmp > 0; + case "le" -> cmp <= 0; + case "ge" -> cmp >= 0; + default -> throw new IllegalArgumentException( + "Unsupported constant comparison operator: " + op); + }; + } + return result ? cb.conjunction() : cb.disjunction(); } - boolean hasArith = operands.stream().anyMatch(o -> - o.getNodeCase() == Operand.NodeCase.EXPRESSION - && ARITHMETIC_OPS.contains(o.getExpression().getOperator())); - if (!hasArith) { - return null; + + private static String typeName(Object o) { + return o == null ? "null" : o.getClass().getSimpleName(); } - if (addFoldSolveOwns(op, operands.get(0), operands.get(1)) - || addFoldSolveOwns(op, operands.get(1), operands.get(0))) { - return null; + + /** + * Compare two mapped columns directly (eq/ne/lt/gt/le/ge) or pattern-match one column + * against another (contains/startsWith/endsWith). Operand source order is preserved — + * two variables rank equally, so {@link NormalizedBinary} never swaps them. + */ + private Predicate fieldToFieldComparison(String op, String leftVar, String rightVar, + Scope scope) { + jakarta.persistence.criteria.Expression left = scope.resolvePath(leftVar); + jakarta.persistence.criteria.Expression right = scope.resolvePath(rightVar); + return switch (op) { + case "eq", "ne", "lt", "gt", "le", "ge" -> comparePredicate(op, left, right); + case "contains" -> fieldToFieldLike(left, right, true, true); + case "startsWith" -> fieldToFieldLike(left, right, false, true); + case "endsWith" -> fieldToFieldLike(left, right, true, false); + default -> throw new IllegalArgumentException( + "Field-to-field comparison is not supported for operator '" + op + "': " + + leftVar + " vs " + rightVar); + }; } - NumericOperand left = resolveNumericOperand(operands.get(0), scope); - NumericOperand right = resolveNumericOperand(operands.get(1), scope); - - // Both sides folded to constants (e.g. ternary substitution producing - // gt(add(1.0, 2.0), 4.0)) — evaluate statically with IEEE semantics. - if (left instanceof NumericOperand.Constant lc - && right instanceof NumericOperand.Constant rc) { - return constantComparison(op, lc.value(), rc.value()); + + /** + * Raw-typed comparison of two SQL expressions — the shared dispatch of field-to-field + * comparisons and arithmetic expression-vs-expression comparisons. Constant-RHS shapes + * do NOT route here: they bind through the plain-value overloads on purpose (double + * bind parameters — see {@link #numericComparison}). + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Predicate comparePredicate(String op, + jakarta.persistence.criteria.Expression left, + jakarta.persistence.criteria.Expression right) { + return switch (op) { + case "eq" -> cb.equal(left, right); + case "ne" -> cb.notEqual(left, right); + case "lt" -> cb.lessThan(left, right); + case "gt" -> cb.greaterThan(left, right); + case "le" -> cb.lessThanOrEqualTo(left, right); + case "ge" -> cb.greaterThanOrEqualTo(left, right); + default -> throw new IllegalArgumentException( + "Unsupported arithmetic comparison operator: " + op); + }; } - // Keep the SQL side on the left (mirroring the operator) so a constant right side - // can bind through the plain-Number overloads. Normalization usually guarantees - // this already, but an expression that FOLDS to a constant (add(1.0, 2.0)) ranks - // as an expression and can still arrive first. - if (left instanceof NumericOperand.Constant) { - NumericOperand tmp = left; - left = right; - right = tmp; - op = NormalizedBinary.mirror(op); + + /** + * {@code haystackColumn LIKE wildcards(escape(needleColumn))} — the column-to-column + * analogue of the constant LIKE path in {@link #defaultLeaf}. The needle is data, so its + * LIKE metacharacters are escaped dynamically with nested {@code REPLACE} (portable: + * H2/Postgres/MySQL/Oracle/SQL Server): {@code \} first, then {@code %} and {@code _}, + * mirroring {@link PlanValues#escapeLike} and the same explicit {@code '\'} escape char. + * + *

The explicit {@code IS NOT NULL} guard on the needle matches CEL (a missing + * attribute is an evaluation error → deny) and also defends against dialects whose + * {@code CONCAT} treats NULL as {@code ''}, which would otherwise turn a NULL needle + * into a match-anything {@code '%%'} pattern. + */ + private Predicate fieldToFieldLike(jakarta.persistence.criteria.Expression haystack, + jakarta.persistence.criteria.Expression needle, + boolean leadingWildcard, boolean trailingWildcard) { + jakarta.persistence.criteria.Expression escaped = + needle.as(String.class); + escaped = cb.function("replace", String.class, + escaped, cb.literal("\\"), cb.literal("\\\\")); + escaped = cb.function("replace", String.class, + escaped, cb.literal("%"), cb.literal("\\%")); + escaped = cb.function("replace", String.class, + escaped, cb.literal("_"), cb.literal("\\_")); + jakarta.persistence.criteria.Expression pattern = escaped; + if (leadingWildcard) { + pattern = cb.concat(cb.literal("%"), pattern); + } + if (trailingWildcard) { + pattern = cb.concat(pattern, cb.literal("%")); + } + return cb.and( + cb.isNotNull(needle), + cb.like(haystack.as(String.class), pattern, '\\')); } - jakarta.persistence.criteria.Expression lhs = - ((NumericOperand.Sql) left).expr(); - if (right instanceof NumericOperand.Constant rc) { - // Plain-value overloads bind the constant as a genuine double PARAMETER; a - // cb.literal would inline `0.3`, which H2/Postgres type as exact NUMERIC and - // drag the comparison out of IEEE space (see resolveNumericOperand). - String cmpOp = op; - double v = rc.value(); - return withOverride(cmpOp, lhs, rc.value(), () -> switch (cmpOp) { - case "eq" -> cb.equal(lhs, v); - case "ne" -> cb.notEqual(lhs, v); - case "lt" -> cb.lt(lhs, v); - case "gt" -> cb.gt(lhs, v); - case "le" -> cb.le(lhs, v); - case "ge" -> cb.ge(lhs, v); - default -> throw new IllegalArgumentException( - "Unsupported arithmetic comparison operator: " + cmpOp); - }); + // -- arithmetic (add/sub/mult/div) as a comparison operand -- + + /** CEL arithmetic operators that can appear as an operand of a comparison. */ + private static final Set ARITHMETIC_OPS = Set.of("add", "sub", "mult", "div", "mod"); + + /** + * Translate {@code cmp(arith(...), other)} — e.g. {@code R.attr.aNumber + 1.0 > 2.0} + * arriving as {@code gt(add(variable, value(1)), value(2))} — by emitting the arithmetic + * on the SQL side ({@code cb.sum}/{@code diff}/{@code prod}/{@code quot}) and comparing. + * + *

Everything is computed and compared in DOUBLE space. This is not a convenience: + * Cerbos attribute values are protobuf {@code Value} numbers, i.e. ALWAYS CEL doubles at + * check time, so the only arithmetic that can evaluate without a no-overload error is + * double-typed — verified against a live PDP: {@code R.attr.n + 1} (int literal) denies + * every row, {@code + 1.0} works, and {@code / 2.0} is true double division + * ({@code 5 / 2.0 == 2.5}). Integer truncation is therefore never observable through the + * check API, and the wire plan erases the int/double distinction anyway (both arrive as + * {@code number_value}). Emitting the arithmetic (rather than solving algebraically) + * also means multiplication/division by negative constants needs no inequality flipping. + * + *

DOUBLE space must be enforced explicitly, because DB decimal arithmetic is not + * IEEE double arithmetic (see {@link #resolveNumericOperand}): columns are CAST, plan + * constants are folded in Java or bound as double parameters, and pure-constant + * comparisons are evaluated statically in Java (full CEL fidelity, Infinity/NaN + * included). + * + *

{@code mod} stays unsupported: CEL {@code %} has no double overload, so on + * attribute values it always errors (deny) — translating it to SQL {@code MOD} would + * fabricate rows the PDP denies. + * + *

{@link OperatorFunction} overrides win here like on every other scalar path when + * the comparison has a plan constant on one side: the arithmetic SQL expression is + * passed as the field argument and the folded constant (always a {@link Double} — the + * arithmetic path is double-space end to end) as the value. Expression-vs-expression + * comparisons (arithmetic against arithmetic or against another column) have no + * (field, value) pair and are not consulted — the same exclusion as field-to-field + * comparisons. + * + *

Only {@link #dispatch} routes here, and only for arithmetic-rooted shapes it did + * not consume as the {@code add} fold ({@code field op add(value, value)}) or the + * eq/ne concat solve — those never enter double space. + */ + private Predicate numericComparison(String op, List operands, Scope scope) { + NumericOperand left = resolveNumericOperand(operands.get(0), scope); + NumericOperand right = resolveNumericOperand(operands.get(1), scope); + + // Both sides folded to constants (e.g. ternary substitution producing + // gt(add(1.0, 2.0), 4.0)) — evaluate statically with IEEE semantics. + if (left instanceof NumericOperand.Constant lc + && right instanceof NumericOperand.Constant rc) { + return constantComparison(op, lc.value(), rc.value()); + } + // Keep the SQL side on the left (mirroring the operator) so a constant right side + // can bind through the plain-Number overloads. Normalization usually guarantees + // this already, but an expression that FOLDS to a constant (add(1.0, 2.0)) ranks + // as an expression and can still arrive first. + if (left instanceof NumericOperand.Constant) { + NumericOperand tmp = left; + left = right; + right = tmp; + op = NormalizedBinary.mirror(op); + } + jakarta.persistence.criteria.Expression lhs = + ((NumericOperand.Sql) left).expr(); + + if (right instanceof NumericOperand.Constant rc) { + // Plain-value overloads bind the constant as a genuine double PARAMETER; a + // cb.literal would inline `0.3`, which H2/Postgres type as exact NUMERIC and + // drag the comparison out of IEEE space (see resolveNumericOperand). + String cmpOp = op; + double v = rc.value(); + return withOverride(cmpOp, lhs, rc.value(), () -> switch (cmpOp) { + case "eq" -> cb.equal(lhs, v); + case "ne" -> cb.notEqual(lhs, v); + case "lt" -> cb.lt(lhs, v); + case "gt" -> cb.gt(lhs, v); + case "le" -> cb.le(lhs, v); + case "ge" -> cb.ge(lhs, v); + default -> throw new IllegalArgumentException( + "Unsupported arithmetic comparison operator: " + cmpOp); + }); + } + + jakarta.persistence.criteria.Expression rhs = + ((NumericOperand.Sql) right).expr(); + return comparePredicate(op, lhs, rhs); } - jakarta.persistence.criteria.Expression rhs = - ((NumericOperand.Sql) right).expr(); - return comparePredicate(op, lhs, rhs); - } + /** + * A resolved arithmetic operand: either a pure-constant subtree folded in Java — + * genuine IEEE double semantics, exactly matching CEL, including division by zero + * yielding ±Infinity/NaN — or a SQL expression forced into double space. + */ + private sealed interface NumericOperand { + record Constant(double value) implements NumericOperand {} + record Sql(jakarta.persistence.criteria.Expression expr) + implements NumericOperand {} + } - /** - * Whether {@code cmp(candidate, other)} is a shape {@link #handleAddComparison} already - * translates — those keep their existing path (constant folding, string concat - * solving, and the {@link OperatorFunction} override hooks): {@code add(value, value)} - * against a field for any operator, and {@code add} of one field and one value against - * a value for eq/ne. - */ - private static boolean addFoldSolveOwns(String op, Operand candidate, Operand other) { - if (candidate.getNodeCase() != Operand.NodeCase.EXPRESSION - || !"add".equals(candidate.getExpression().getOperator()) - || candidate.getExpression().getOperandsCount() != 2) { - return false; + /** + * Resolve a comparison operand to double space. DB decimal arithmetic is NOT IEEE + * double arithmetic: H2 (and Postgres) type a bare {@code 0.1} literal as exact + * NUMERIC and evaluate {@code intCol * 0.1} decimally, so {@code aNumber * 0.1 == 0.3} + * matched rows the PDP (IEEE: {@code 0.30000000000000004}) denies. Verified against + * H2 2.3: only {@code CAST(col AS DOUBLE) * CAST(0.1 AS DOUBLE)} diverges from + * {@code 0.3}; {@code Expression.as(Double.class)} renders NO SQL cast (it is a type + * marker only) and {@code cb.toDouble(literal)} elides the cast on a node already + * Double-typed, both leaving the arithmetic decimal. Therefore: + *

    + *
  • columns go through {@code cb.toDouble} (renders {@code cast(col as float(53))});
  • + *
  • constant subtrees fold in Java ({@link NumericOperand.Constant});
  • + *
  • constants mixed into SQL arithmetic bind through the plain-{@code Number} + * CriteriaBuilder overloads, which emit genuine double-typed bind parameters + * instead of decimal literals.
  • + *
+ * + *

Division guard: SQL raises an error on a zero divisor — a data-dependent runtime + * failure of the WHOLE query — while CEL double division is defined (±Infinity, or NaN + * for 0/0). Portable Infinity semantics are not expressible in SQL, so a column + * divisor is wrapped in {@code NULLIF(d, 0)}: zero-divisor rows become UNKNOWN → + * EXCLUDED. Documented divergence, under-inclusive in the safe direction: CEL would + * ALLOW rows where the comparison against ±Infinity holds (e.g. {@code x/0 > 1} with + * {@code x > 0}); the adapter denies them, and the query survives. For 0/0 CEL yields + * NaN, whose comparisons are all false (deny) — the exclusion matches exactly. + * Constant divisors are decided statically: non-zero skips the guard, zero collapses + * the division to a NULL literal (UNKNOWN for every row). + */ + private NumericOperand resolveNumericOperand(Operand operand, Scope scope) { + switch (operand.getNodeCase()) { + case VARIABLE -> { + @SuppressWarnings("unchecked") + jakarta.persistence.criteria.Expression path = + (jakarta.persistence.criteria.Expression) + scope.resolvePath(operand.getVariable()); + return new NumericOperand.Sql(cb.toDouble(path)); + } + case VALUE -> { + Object v = PlanValues.protoValueToJava(operand.getValue()); + if (!(v instanceof Number n)) { + throw new IllegalArgumentException( + "Arithmetic comparison requires numeric operands, got " + + typeName(v)); + } + return new NumericOperand.Constant(n.doubleValue()); + } + case EXPRESSION -> { + PlanResourcesFilter.Expression expr = operand.getExpression(); + String op = expr.getOperator(); + if ("mod".equals(op)) { + throw new IllegalArgumentException( + "mod is not supported in comparisons: CEL % is integer-only and " + + "attribute values are always doubles at check time, so " + + "the condition can never be satisfied by the PDP"); + } + if (!ARITHMETIC_OPS.contains(op)) { + throw new IllegalArgumentException( + "Unexpected " + op + "() expression inside an arithmetic " + + "comparison operand"); + } + if (expr.getOperandsCount() != 2) { + throw new IllegalArgumentException(op + " requires exactly 2 operands"); + } + NumericOperand l = resolveNumericOperand(expr.getOperands(0), scope); + NumericOperand r = resolveNumericOperand(expr.getOperands(1), scope); + if (l instanceof NumericOperand.Constant lc + && r instanceof NumericOperand.Constant rc) { + return new NumericOperand.Constant(switch (op) { + case "add" -> lc.value() + rc.value(); + case "sub" -> lc.value() - rc.value(); + case "mult" -> lc.value() * rc.value(); + case "div" -> lc.value() / rc.value(); // IEEE: ±Infinity, 0/0 = NaN + default -> throw new IllegalArgumentException( + "Unsupported arithmetic operator: " + op); + }); + } + return new NumericOperand.Sql(arithmeticSql(op, l, r)); + } + default -> throw new IllegalArgumentException( + "Unexpected operand type in arithmetic comparison: " + + operand.getNodeCase()); + } } - Operand l = candidate.getExpression().getOperands(0); - Operand r = candidate.getExpression().getOperands(1); - boolean bothValues = l.getNodeCase() == Operand.NodeCase.VALUE - && r.getNodeCase() == Operand.NodeCase.VALUE; - if (bothValues && other.getNodeCase() == Operand.NodeCase.VARIABLE) { - return true; // fold path + + /** + * Emit one SQL arithmetic node; at least one side is a SQL expression. Constants go + * through the plain-{@code Number} overloads (double bind parameters — see + * {@link #resolveNumericOperand}). + */ + private jakarta.persistence.criteria.Expression arithmeticSql( + String op, NumericOperand l, NumericOperand r) { + jakarta.persistence.criteria.Expression le = + l instanceof NumericOperand.Sql s ? s.expr() : null; + jakarta.persistence.criteria.Expression re = + r instanceof NumericOperand.Sql s ? s.expr() : null; + Double lc = l instanceof NumericOperand.Constant c ? c.value() : null; + Double rc = r instanceof NumericOperand.Constant c ? c.value() : null; + return switch (op) { + case "add" -> le == null ? cb.sum(lc, re) + : re == null ? cb.sum(le, rc) : cb.sum(le, re); + case "sub" -> le == null ? cb.diff(lc, re) + : re == null ? cb.diff(le, rc) : cb.diff(le, re); + case "mult" -> le == null ? cb.prod(lc, re) + : re == null ? cb.prod(le, rc) : cb.prod(le, re); + case "div" -> divisionSql(le, lc, re, rc); + default -> throw new IllegalArgumentException( + "Unsupported arithmetic operator: " + op); + }; } - boolean oneFieldOneValue = - (l.getNodeCase() == Operand.NodeCase.VARIABLE - && r.getNodeCase() == Operand.NodeCase.VALUE) - || (l.getNodeCase() == Operand.NodeCase.VALUE - && r.getNodeCase() == Operand.NodeCase.VARIABLE); - return ("eq".equals(op) || "ne".equals(op)) - && oneFieldOneValue - && other.getNodeCase() == Operand.NodeCase.VALUE; // solve path - } - /** - * A resolved arithmetic operand: either a pure-constant subtree folded in Java — - * genuine IEEE double semantics, exactly matching CEL, including division by zero - * yielding ±Infinity/NaN — or a SQL expression forced into double space. - */ - private sealed interface NumericOperand { - record Constant(double value) implements NumericOperand {} - record Sql(jakarta.persistence.criteria.Expression expr) - implements NumericOperand {} - } + /** Division with the NULLIF zero-divisor guard (see {@link #resolveNumericOperand}). */ + private jakarta.persistence.criteria.Expression divisionSql( + jakarta.persistence.criteria.Expression le, Double lc, + jakarta.persistence.criteria.Expression re, Double rc) { + if (rc != null) { + // Constant divisor, numerator is a SQL expression (both-constant subtrees + // fold before reaching here). Zero → UNKNOWN for every row; non-zero → no + // guard needed. + if (rc == 0.0) { + return cb.nullLiteral(Double.class); + } + return cb.quot(le, rc).as(Double.class); + } + jakarta.persistence.criteria.Expression guarded = cb.nullif(re, 0.0); + return (lc != null ? cb.quot(lc, guarded) : cb.quot(le, guarded)).as(Double.class); + } + + // -- size(collection) N -- + + /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ + private Predicate trySizeComparison(String op, List operands, Scope scope) { + // Detect the size() operand first: every ordinary leaf comparison probes through + // here, and converting the VALUE operand up front would materialize lists/structs + // only to discard them when no size() expression is present. + PlanResourcesFilter.Expression sizeExpr = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "size".equals(o.getExpression().getOperator())) { + sizeExpr = o.getExpression(); + } + } + if (sizeExpr == null) { + return null; + } + Double numRaw = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.VALUE + && o.getValue().getKindCase() == Value.KindCase.NUMBER_VALUE) { + numRaw = o.getValue().getNumberValue(); + } + } + if (numRaw == null) { + return null; + } - /** - * Resolve a comparison operand to double space. DB decimal arithmetic is NOT IEEE - * double arithmetic: H2 (and Postgres) type a bare {@code 0.1} literal as exact - * NUMERIC and evaluate {@code intCol * 0.1} decimally, so {@code aNumber * 0.1 == 0.3} - * matched rows the PDP (IEEE: {@code 0.30000000000000004}) denies. Verified against - * H2 2.3: only {@code CAST(col AS DOUBLE) * CAST(0.1 AS DOUBLE)} diverges from - * {@code 0.3}; {@code Expression.as(Double.class)} renders NO SQL cast (it is a type - * marker only) and {@code cb.toDouble(literal)} elides the cast on a node already - * Double-typed, both leaving the arithmetic decimal. Therefore: - *

    - *
  • columns go through {@code cb.toDouble} (renders {@code cast(col as float(53))});
  • - *
  • constant subtrees fold in Java ({@link NumericOperand.Constant});
  • - *
  • constants mixed into SQL arithmetic bind through the plain-{@code Number} - * CriteriaBuilder overloads, which emit genuine double-typed bind parameters - * instead of decimal literals.
  • - *
- * - *

Division guard: SQL raises an error on a zero divisor — a data-dependent runtime - * failure of the WHOLE query — while CEL double division is defined (±Infinity, or NaN - * for 0/0). Portable Infinity semantics are not expressible in SQL, so a column - * divisor is wrapped in {@code NULLIF(d, 0)}: zero-divisor rows become UNKNOWN → - * EXCLUDED. Documented divergence, under-inclusive in the safe direction: CEL would - * ALLOW rows where the comparison against ±Infinity holds (e.g. {@code x/0 > 1} with - * {@code x > 0}); the adapter denies them, and the query survives. For 0/0 CEL yields - * NaN, whose comparisons are all false (deny) — the exclusion matches exactly. - * Constant divisors are decided statically: non-zero skips the guard, zero collapses - * the division to a NULL literal (UNKNOWN for every row). - */ - private NumericOperand resolveNumericOperand(Operand operand, Scope scope) { - switch (operand.getNodeCase()) { - case VARIABLE -> { - @SuppressWarnings("unchecked") - jakarta.persistence.criteria.Expression path = - (jakarta.persistence.criteria.Expression) - scope.resolvePath(operand.getVariable()); - return new NumericOperand.Sql(cb.toDouble(path)); + // Fractional thresholds: COUNT/LENGTH are integral, so a fractional constant f can + // never be hit exactly. Truncating (`>= 1.5` becoming `>= 1`) over-included rows + // the PDP denies. Correct integer-count semantics: + // eq f → always-false + // ne f → always-true (Field-mapping NULL caveat handled below: a NULL + // string column is a missing attribute → CEL error → deny) + // ge f/gt f → ge ceil(f) (the count being integral makes gt and ge coincide) + // le f/lt f → le floor(f) + // Integral thresholds keep the operator untouched. The always-true/false collapses + // flow through the same constant predicates the other static folds use + // (cb.conjunction()/cb.disjunction()), so the size(filter(...)) unknown-element + // machinery below still wraps them. + String cmpOp = op; + long numValue; + Boolean fractionalCollapse = null; // TRUE → always-true, FALSE → always-false + if (numRaw != Math.rint(numRaw)) { + switch (op) { + case "eq" -> fractionalCollapse = Boolean.FALSE; + case "ne" -> fractionalCollapse = Boolean.TRUE; + case "gt", "ge" -> cmpOp = "ge"; + case "lt", "le" -> cmpOp = "le"; + default -> throw new IllegalArgumentException( + "Unsupported size comparison operator: " + op); + } + numValue = "ge".equals(cmpOp) + ? (long) Math.ceil(numRaw) + : (long) Math.floor(numRaw); + } else { + numValue = numRaw.longValue(); } - case VALUE -> { - Object v = PlanValues.protoValueToJava(operand.getValue()); - if (!(v instanceof Number n)) { - throw new IllegalArgumentException( - "Arithmetic comparison requires numeric operands, got " - + typeName(v)); + List sizeOps = sizeExpr.getOperandsList(); + if (sizeOps.size() != 1) { + throw new IllegalArgumentException("Unsupported size() expression"); + } + Operand sizeArg = sizeOps.get(0); + String var; + Operand lambdaBody = null; + String lambdaVarName = null; + if (sizeArg.getNodeCase() == Operand.NodeCase.VARIABLE) { + var = sizeArg.getVariable(); + } else if (sizeArg.getNodeCase() == Operand.NodeCase.EXPRESSION + && "filter".equals(sizeArg.getExpression().getOperator())) { + // size(coll.filter(x, pred)) — count only the elements matching the lambda. + List filterOps = sizeArg.getExpression().getOperandsList(); + if (filterOps.size() != 2 + || filterOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("Unsupported size(filter(...)) expression"); } - return new NumericOperand.Constant(n.doubleValue()); + var = filterOps.get(0).getVariable(); + ParsedLambda lambda = parseLambda(filterOps.get(1), + "Unsupported size(filter(...)) expression", + "lambda requires exactly 2 operands", + "lambda requires exactly 2 operands"); + lambdaBody = lambda.body(); + lambdaVarName = lambda.varName(); + } else { + throw new IllegalArgumentException("Unsupported size() expression"); } - case EXPRESSION -> { - PlanResourcesFilter.Expression expr = operand.getExpression(); - String op = expr.getOperator(); - if ("mod".equals(op)) { + Scope.ResolvedRelation ref = scope.resolveRelation(var); + if (ref == null) { + AttributeMapping mapping = scope.resolveMapping(var); + if (!(mapping instanceof AttributeMapping.Field)) { throw new IllegalArgumentException( - "mod is not supported in comparisons: CEL % is integer-only and " - + "attribute values are always doubles at check time, so " - + "the condition can never be satisfied by the PDP"); + "size() requires a collection (Relation) mapping for " + var); } - if (!ARITHMETIC_OPS.contains(op)) { + // size(string) — CEL string length → LENGTH(column) N. + if (lambdaBody != null) { throw new IllegalArgumentException( - "Unexpected " + op + "() expression inside an arithmetic " - + "comparison operand"); + "size(filter(...)) requires a collection (Relation) mapping for " + var); } - if (expr.getOperandsCount() != 2) { - throw new IllegalArgumentException(op + " requires exactly 2 operands"); + Path path = scope.resolvePath(var); + if (fractionalCollapse != null) { + // ne f is vacuously true only for a PRESENT string: a NULL column is a + // missing attribute → CEL error → deny, so it must stay excluded — + // IS NOT NULL, never an unconditional 1=1. eq f excludes everything. + return fractionalCollapse ? cb.isNotNull(path) : cb.disjunction(); } - NumericOperand l = resolveNumericOperand(expr.getOperands(0), scope); - NumericOperand r = resolveNumericOperand(expr.getOperands(1), scope); - if (l instanceof NumericOperand.Constant lc - && r instanceof NumericOperand.Constant rc) { - return new NumericOperand.Constant(switch (op) { - case "add" -> lc.value() + rc.value(); - case "sub" -> lc.value() - rc.value(); - case "mult" -> lc.value() * rc.value(); - case "div" -> lc.value() / rc.value(); // IEEE: ±Infinity, 0/0 = NaN - default -> throw new IllegalArgumentException( - "Unsupported arithmetic operator: " + op); - }); - } - return new NumericOperand.Sql(arithmeticSql(op, l, r)); + return compareCount(cb.length(path.as(String.class)), cmpOp, (int) numValue); } - default -> throw new IllegalArgumentException( - "Unexpected operand type in arithmetic comparison: " - + operand.getNodeCase()); - } - } - - /** - * Emit one SQL arithmetic node; at least one side is a SQL expression. Constants go - * through the plain-{@code Number} overloads (double bind parameters — see - * {@link #resolveNumericOperand}). - */ - private jakarta.persistence.criteria.Expression arithmeticSql( - String op, NumericOperand l, NumericOperand r) { - jakarta.persistence.criteria.Expression le = - l instanceof NumericOperand.Sql s ? s.expr() : null; - jakarta.persistence.criteria.Expression re = - r instanceof NumericOperand.Sql s ? s.expr() : null; - Double lc = l instanceof NumericOperand.Constant c ? c.value() : null; - Double rc = r instanceof NumericOperand.Constant c ? c.value() : null; - return switch (op) { - case "add" -> le == null ? cb.sum(lc, re) - : re == null ? cb.sum(le, rc) : cb.sum(le, re); - case "sub" -> le == null ? cb.diff(lc, re) - : re == null ? cb.diff(le, rc) : cb.diff(le, re); - case "mult" -> le == null ? cb.prod(lc, re) - : re == null ? cb.prod(le, rc) : cb.prod(le, re); - case "div" -> divisionSql(le, lc, re, rc); - default -> throw new IllegalArgumentException( - "Unsupported arithmetic operator: " + op); - }; - } + final Operand fBody = lambdaBody; + final String fVar = lambdaVarName; + SubqueryBodyBuilder bodyBuilder = (sub, tailJoin, rebased) -> + fBody == null ? cb.conjunction() + : traverse(fBody, Scope.lambda(tailJoin, sub, ref.tail(), fVar, rebased)); - /** Division with the NULLIF zero-divisor guard (see {@link #resolveNumericOperand}). */ - private jakarta.persistence.criteria.Expression divisionSql( - jakarta.persistence.criteria.Expression le, Double lc, - jakarta.persistence.criteria.Expression re, Double rc) { - if (rc != null) { - // Constant divisor, numerator is a SQL expression (both-constant subtrees - // fold before reaching here). Zero → UNKNOWN for every row; non-zero → no - // guard needed. - if (rc == 0.0) { - return cb.nullLiteral(Double.class); + Predicate base; + if (fractionalCollapse != null) { + // A Relation count is always defined (an empty join is count 0), so the + // fractional eq/ne collapse is unconditional here. Falls through to the + // size(filter(...)) unknown-element guard below so an erroring lambda body + // still denies the row. + base = fractionalCollapse ? cb.conjunction() : cb.disjunction(); + } else { + boolean nonEmpty = ("gt".equals(cmpOp) && numValue == 0L) + || ("ge".equals(cmpOp) && numValue == 1L); + boolean empty = ("eq".equals(cmpOp) && numValue == 0L) + || ("le".equals(cmpOp) && numValue == 0L) + || ("lt".equals(cmpOp) && numValue == 1L); + if (nonEmpty) { + base = existsSubquery(scope, ref, bodyBuilder); + } else if (empty) { + base = tri.not(existsSubquery(scope, ref, bodyBuilder)); + } else { + // Arbitrary N → correlated (SELECT COUNT(...)) N, same shape as + // exists_one. For a multi-hop chain the COUNT joins through every hop, so it + // counts the FLATTENED tail elements — the same element set the EXISTS + // shortcuts range over. + ChainSubquery cs = countSubquery(scope, ref); + if (fBody != null) { + cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); + } + base = compareCount(cs.sub(), cmpOp, numValue); + } } - return cb.quot(le, rc).as(Double.class); - } - jakarta.persistence.criteria.Expression guarded = cb.nullif(re, 0.0); - return (lc != null ? cb.quot(lc, guarded) : cb.quot(le, guarded)).as(Double.class); - } - - // -- add (fold + solve for string concat / numeric translation) -- - - /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ - private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression addExpr, - Operand otherOperand, Scope scope) { - List addOperands = addExpr.getOperandsList(); - if (addOperands.size() != 2) { - throw new IllegalArgumentException("add requires exactly 2 operands"); - } - Operand addLeft = addOperands.get(0); - Operand addRight = addOperands.get(1); - - // Case 1: add(value, value) — fold the two constants, then compare to the field. - // Normalization guarantees the field variable sits on the left of the comparison, - // so the folded constant compares as `field op folded`. - if (addLeft.getNodeCase() == Operand.NodeCase.VALUE - && addRight.getNodeCase() == Operand.NodeCase.VALUE) { - Object folded = PlanValues.foldAdd( - PlanValues.protoValueToJava(addLeft.getValue()), - PlanValues.protoValueToJava(addRight.getValue())); - if (otherOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { - throw new IllegalArgumentException( - "add(const, const) compared to a non-field operand is not supported"); + if (fBody == null) { + // size(collection) counts rows without evaluating a lambda — no element can be + // UNKNOWN, so the plain comparison is already exact. + return base; } - Path path = scope.resolvePath(otherOperand.getVariable()); - return applyLeaf(op, path, folded); + // size(coll.filter(x, pred)): CEL filter has NO error absorption — any element whose + // predicate errors (NULL-derived UNKNOWN body) errors the whole expression (deny), + // even when the count comparison would otherwise hold. Same strict table as + // exists_one: TriPredicate.baseUnlessUnknown. + return tri.baseUnlessUnknown(base, + () -> unknownElementExists(scope, ref, bodyBuilder)); } - // Case 2: add(field, value) or add(value, field) — solve for the field. - // Only eq/ne are supported; lt/gt/etc. against a synthesized expression would require - // emitting more complex predicates we don't try to support here. - if (!"eq".equals(op) && !"ne".equals(op)) { - throw new IllegalArgumentException( - "add comparison with a field reference only supports eq/ne (got " + op + ")"); - } - if (otherOperand.getNodeCase() != Operand.NodeCase.VALUE) { - throw new IllegalArgumentException( - "add(field, value) requires a value on the other side of the comparison"); - } - Object otherValue = PlanValues.protoValueToJava(otherOperand.getValue()); - - Operand fieldOp; - Object addConst; - boolean fieldIsLeft; - if (addLeft.getNodeCase() == Operand.NodeCase.VARIABLE - && addRight.getNodeCase() == Operand.NodeCase.VALUE) { - fieldOp = addLeft; - addConst = PlanValues.protoValueToJava(addRight.getValue()); - fieldIsLeft = true; - } else if (addLeft.getNodeCase() == Operand.NodeCase.VALUE - && addRight.getNodeCase() == Operand.NodeCase.VARIABLE) { - fieldOp = addRight; - addConst = PlanValues.protoValueToJava(addLeft.getValue()); - fieldIsLeft = false; - } else { - throw new IllegalArgumentException( - "add requires exactly one field reference and one value, or two values"); - } - - Object solved = PlanValues.solveAdd(otherValue, addConst, fieldIsLeft); - if (solved == null) { - // No solution exists (e.g. "projects:123" == "users:" + R.id can never be true). - // eq → always-false. ne is NOT always-true: a missing attribute makes the - // concatenation a CEL evaluation error ("users:" + null) → deny, so NULL rows - // must stay excluded — IS NOT NULL, not an unconditional 1=1 (which leaked - // exactly the rows the PDP denies). - if ("eq".equals(op)) { - return cb.disjunction(); - } - return cb.isNotNull(scope.resolvePath(fieldOp.getVariable())); + /** Compare a numeric size expression (COUNT subquery or LENGTH) against a constant. */ + private > Predicate compareCount( + jakarta.persistence.criteria.Expression count, String op, N n) { + return switch (op) { + case "eq" -> cb.equal(count, n); + case "ne" -> cb.notEqual(count, n); + case "lt" -> cb.lessThan(count, n); + case "gt" -> cb.greaterThan(count, n); + case "le" -> cb.lessThanOrEqualTo(count, n); + case "ge" -> cb.greaterThanOrEqualTo(count, n); + default -> throw new IllegalArgumentException( + "Unsupported size comparison operator: " + op); + }; } - Path path = scope.resolvePath(fieldOp.getVariable()); - return applyLeaf(op, path, solved); } + // -- end ComparisonTranslator -- // -- isSet -- @@ -1222,32 +1487,18 @@ private Predicate handleMapIntersection(PlanResourcesFilter.Expression mapExpr, throw new IllegalArgumentException( "map can only be applied to a collection mapped as Relation: " + collectionVar); } - return mapIntersectionWithNullGuard( + // CEL map() has no error absorption: a NULL projected column is a missing element + // attribute, so the whole hasIntersection(map(...), values) is an evaluation error + // (deny) even when another element would intersect — the strict + // TriPredicate.baseUnlessUnknown table, with the null-witness EXISTS as the unknown + // detector (IS NULL itself is two-valued, so both EXISTS legs are safe to compose). + return tri.baseUnlessUnknown( existsSubquery(scope, ref, (sub, tailJoin, rebased) -> Scope.memberPath(tailJoin, ref.tail(), memberField).in(values)), () -> existsSubquery(scope, ref, (sub, tailJoin, rebased) -> cb.isNull(Scope.memberPath(tailJoin, ref.tail(), memberField)))); } - /** - * CEL {@code map()} has no error absorption: a NULL projected column is a missing element - * attribute, so the whole {@code hasIntersection(map(...), values)} is an evaluation - * error (deny) even when another element would intersect. Truth table: - *

    - *
  • no NULL projection → {@code (in AND TRUE) OR (FALSE AND UNKNOWN)} = in-EXISTS
  • - *
  • ≥1 NULL projection → {@code (in AND FALSE) OR (TRUE AND UNKNOWN)} = UNKNOWN (deny)
  • - *
- * The null-witness EXISTS is supplied as a factory and built fresh per occurrence - * (Hibernate 6 negation is stateful — see {@link #negate}); {@code IS NULL} itself is - * two-valued, so both EXISTS legs are safe to compose. - */ - private Predicate mapIntersectionWithNullGuard(Predicate inExists, - Supplier nullProjectionExists) { - return cb.or( - cb.and(inExists, negate(nullProjectionExists.get())), - cb.and(nullProjectionExists.get(), unknownPredicate())); - } - private Predicate collectionContainsAny(Scope scope, Scope.ResolvedRelation ref, List values) { // Intersection with an empty value set is always false — and an EXISTS wrapping an // empty `IN ()` is dialect-dependent — so short-circuit before building the subquery. @@ -1263,178 +1514,6 @@ private Predicate collectionContainsAny(Scope scope, Scope.ResolvedRelation ref, }); } - // -- size(collection) N -- - - /** Operands must already be normalized field-first (see {@link NormalizedBinary}). */ - private Predicate trySizeComparison(String op, List operands, Scope scope) { - // Detect the size() operand first: every ordinary leaf comparison probes through - // here, and converting the VALUE operand up front would materialize lists/structs - // only to discard them when no size() expression is present. - PlanResourcesFilter.Expression sizeExpr = null; - for (Operand o : operands) { - if (o.getNodeCase() == Operand.NodeCase.EXPRESSION - && "size".equals(o.getExpression().getOperator())) { - sizeExpr = o.getExpression(); - } - } - if (sizeExpr == null) { - return null; - } - Double numRaw = null; - for (Operand o : operands) { - if (o.getNodeCase() == Operand.NodeCase.VALUE - && o.getValue().getKindCase() == Value.KindCase.NUMBER_VALUE) { - numRaw = o.getValue().getNumberValue(); - } - } - if (numRaw == null) { - return null; - } - - // Fractional thresholds: COUNT/LENGTH are integral, so a fractional constant f can - // never be hit exactly. Truncating (`>= 1.5` becoming `>= 1`) over-included rows - // the PDP denies. Correct integer-count semantics: - // eq f → always-false - // ne f → always-true (Field-mapping NULL caveat handled below: a NULL - // string column is a missing attribute → CEL error → deny) - // ge f/gt f → ge ceil(f) (the count being integral makes gt and ge coincide) - // le f/lt f → le floor(f) - // Integral thresholds keep the operator untouched. The always-true/false collapses - // flow through the same constant predicates the other static folds use - // (cb.conjunction()/cb.disjunction()), so the size(filter(...)) unknown-element - // machinery below still wraps them. - String cmpOp = op; - long numValue; - Boolean fractionalCollapse = null; // TRUE → always-true, FALSE → always-false - if (numRaw != Math.rint(numRaw)) { - switch (op) { - case "eq" -> fractionalCollapse = Boolean.FALSE; - case "ne" -> fractionalCollapse = Boolean.TRUE; - case "gt", "ge" -> cmpOp = "ge"; - case "lt", "le" -> cmpOp = "le"; - default -> throw new IllegalArgumentException( - "Unsupported size comparison operator: " + op); - } - numValue = "ge".equals(cmpOp) - ? (long) Math.ceil(numRaw) - : (long) Math.floor(numRaw); - } else { - numValue = numRaw.longValue(); - } - List sizeOps = sizeExpr.getOperandsList(); - if (sizeOps.size() != 1) { - throw new IllegalArgumentException("Unsupported size() expression"); - } - Operand sizeArg = sizeOps.get(0); - String var; - Operand lambdaBody = null; - String lambdaVarName = null; - if (sizeArg.getNodeCase() == Operand.NodeCase.VARIABLE) { - var = sizeArg.getVariable(); - } else if (sizeArg.getNodeCase() == Operand.NodeCase.EXPRESSION - && "filter".equals(sizeArg.getExpression().getOperator())) { - // size(coll.filter(x, pred)) — count only the elements matching the lambda. - List filterOps = sizeArg.getExpression().getOperandsList(); - if (filterOps.size() != 2 - || filterOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE) { - throw new IllegalArgumentException("Unsupported size(filter(...)) expression"); - } - var = filterOps.get(0).getVariable(); - ParsedLambda lambda = parseLambda(filterOps.get(1), - "Unsupported size(filter(...)) expression", - "lambda requires exactly 2 operands", - "lambda requires exactly 2 operands"); - lambdaBody = lambda.body(); - lambdaVarName = lambda.varName(); - } else { - throw new IllegalArgumentException("Unsupported size() expression"); - } - Scope.ResolvedRelation ref = scope.resolveRelation(var); - if (ref == null) { - AttributeMapping mapping = scope.resolveMapping(var); - if (!(mapping instanceof AttributeMapping.Field)) { - throw new IllegalArgumentException( - "size() requires a collection (Relation) mapping for " + var); - } - // size(string) — CEL string length → LENGTH(column) N. - if (lambdaBody != null) { - throw new IllegalArgumentException( - "size(filter(...)) requires a collection (Relation) mapping for " + var); - } - Path path = scope.resolvePath(var); - if (fractionalCollapse != null) { - // ne f is vacuously true only for a PRESENT string: a NULL column is a - // missing attribute → CEL error → deny, so it must stay excluded — - // IS NOT NULL, never an unconditional 1=1. eq f excludes everything. - return fractionalCollapse ? cb.isNotNull(path) : cb.disjunction(); - } - return compareCount(cb.length(path.as(String.class)), cmpOp, (int) numValue); - } - final Operand fBody = lambdaBody; - final String fVar = lambdaVarName; - SubqueryBodyBuilder bodyBuilder = (sub, tailJoin, rebased) -> - fBody == null ? cb.conjunction() - : traverse(fBody, Scope.lambda(tailJoin, sub, ref.tail(), fVar, rebased)); - - Predicate base; - if (fractionalCollapse != null) { - // A Relation count is always defined (an empty join is count 0), so the - // fractional eq/ne collapse is unconditional here. Falls through to the - // size(filter(...)) unknown-element guard below so an erroring lambda body - // still denies the row. - base = fractionalCollapse ? cb.conjunction() : cb.disjunction(); - } else { - boolean nonEmpty = ("gt".equals(cmpOp) && numValue == 0L) - || ("ge".equals(cmpOp) && numValue == 1L); - boolean empty = ("eq".equals(cmpOp) && numValue == 0L) - || ("le".equals(cmpOp) && numValue == 0L) - || ("lt".equals(cmpOp) && numValue == 1L); - if (nonEmpty) { - base = existsSubquery(scope, ref, bodyBuilder); - } else if (empty) { - base = negate(existsSubquery(scope, ref, bodyBuilder)); - } else { - // Arbitrary N → correlated (SELECT COUNT(...)) N, same shape as - // exists_one. For a multi-hop chain the COUNT joins through every hop, so it - // counts the FLATTENED tail elements — the same element set the EXISTS - // shortcuts range over. - ChainSubquery cs = countSubquery(scope, ref); - if (fBody != null) { - cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); - } - base = compareCount(cs.sub(), cmpOp, numValue); - } - } - if (fBody == null) { - // size(collection) counts rows without evaluating a lambda — no element can be - // UNKNOWN, so the plain comparison is already exact. - return base; - } - // size(coll.filter(x, pred)): CEL filter has NO error absorption — any element whose - // predicate errors (NULL-derived UNKNOWN body) errors the whole expression (deny), - // even when the count comparison would otherwise hold. Same combinator as exists_one: - // ≥1 UNKNOWN element → (base AND FALSE) OR (TRUE AND UNKNOWN) = UNKNOWN (deny) - // no UNKNOWN element → (base AND TRUE) OR (FALSE AND …) = base - return cb.or( - cb.and(base, negate(unknownElementExists(scope, ref, bodyBuilder))), - cb.and(unknownElementExists(scope, ref, bodyBuilder), unknownPredicate())); - } - - /** Compare a numeric size expression (COUNT subquery or LENGTH) against a constant. */ - private > Predicate compareCount( - jakarta.persistence.criteria.Expression count, String op, N n) { - return switch (op) { - case "eq" -> cb.equal(count, n); - case "ne" -> cb.notEqual(count, n); - case "lt" -> cb.lessThan(count, n); - case "gt" -> cb.greaterThan(count, n); - case "le" -> cb.lessThanOrEqualTo(count, n); - case "ge" -> cb.greaterThanOrEqualTo(count, n); - default -> throw new IllegalArgumentException( - "Unsupported size comparison operator: " + op); - }; - } - // -- exists / exists_one / all / except / filter -- /** @@ -1454,8 +1533,8 @@ private > Predicate compareCount( * is UNKNOWN silently fails to match, collapsing the error case to FALSE — which * {@code not(...)} flips to TRUE, an authorization leak. Building blocks: * {@code EXISTS(elem WHERE body)} (true witness), {@code EXISTS(elem WHERE NOT body)} - * (false witness), {@link #unknownElementExists} (any UNKNOWN-body element) and - * {@link #unknownPredicate} (a constant SQL UNKNOWN to compose with). + * (false witness) and {@link #unknownElementExists} (any UNKNOWN-body element); the + * truth tables composing them live in {@link TriPredicate}. * *

{@code filter}/{@code except} in boolean position are kept consistent with the * {@code exists} family. Cost note: the unknown machinery is always emitted — each @@ -1498,42 +1577,34 @@ private Predicate handleCollectionOperator(String op, List operands, Sc String lambdaVarName = lambda.varName(); // Every invocation re-traverses the body, so each occurrence gets a fresh Predicate - // tree (Hibernate 6 negation is stateful — see negate()). + // tree (Hibernate 6 negation is stateful — see TriPredicate.not()). SubqueryBodyBuilder bodyBuilder = (sub, tailJoin, rebased) -> traverse(body, Scope.lambda(tailJoin, sub, ref.tail(), lambdaVarName, rebased)); SubqueryBodyBuilder negatedBodyBuilder = (sub, tailJoin, rebased) -> - negate(bodyBuilder.build(sub, tailJoin, rebased)); + tri.not(bodyBuilder.build(sub, tailJoin, rebased)); return switch (op) { - // exists (and filter): true-witness OR (unknown-element AND UNKNOWN). - // any TRUE element → TRUE OR … = TRUE (absorbed) - // no TRUE, ≥1 UNKNOWN element → FALSE OR (TRUE AND UNKNOWN) = UNKNOWN (deny) - // no TRUE, no UNKNOWN → FALSE OR (FALSE AND UNKNOWN) = FALSE - case "exists", "filter" -> cb.or( + // exists (and filter): OR with error absorption — TriPredicate.anyTrueOrUnknown. + case "exists", "filter" -> tri.anyTrueOrUnknown( existsSubquery(scope, ref, bodyBuilder), - cb.and(unknownElementExists(scope, ref, bodyBuilder), unknownPredicate())); + unknownElementExists(scope, ref, bodyBuilder)); // except is "some element fails the body" — the exists table with the false // witness in the true-witness seat; an UNKNOWN body is UNKNOWN under NOT too. - case "except" -> cb.or( + case "except" -> tri.anyTrueOrUnknown( + existsSubquery(scope, ref, negatedBodyBuilder), + unknownElementExists(scope, ref, bodyBuilder)); + // all: AND with error absorption — TriPredicate.allTrueOrUnknown, with the + // false witness EXISTS(elem WHERE NOT body). + case "all" -> tri.allTrueOrUnknown( existsSubquery(scope, ref, negatedBodyBuilder), - cb.and(unknownElementExists(scope, ref, bodyBuilder), unknownPredicate())); - // all: NOT false-witness AND (NOT unknown-element OR UNKNOWN). - // any FALSE element → FALSE AND … = FALSE (absorbed) - // no FALSE, ≥1 UNKNOWN element → TRUE AND (FALSE OR UNKNOWN) = UNKNOWN (deny) - // no FALSE, no UNKNOWN → TRUE AND (TRUE OR UNKNOWN) = TRUE - case "all" -> cb.and( - negate(existsSubquery(scope, ref, negatedBodyBuilder)), - cb.or(negate(unknownElementExists(scope, ref, bodyBuilder)), unknownPredicate())); - // exists_one: (COUNT(body) = 1 AND NOT unknown-element) OR (unknown-element AND UNKNOWN). - // ≥1 UNKNOWN element → (… AND FALSE) OR (TRUE AND UNKNOWN) = UNKNOWN (deny) - // no UNKNOWN element → (COUNT=1 AND TRUE) OR (FALSE AND …) = COUNT=1 + unknownElementExists(scope, ref, bodyBuilder)); + // exists_one: strict — any UNKNOWN element denies, else COUNT(body) = 1 — + // TriPredicate.baseUnlessUnknown. case "exists_one" -> { ChainSubquery cs = countSubquery(scope, ref); cs.sub().where(bodyBuilder.build(cs.sub(), cs.tailJoin(), cs.rebasedOuter())); - yield cb.or( - cb.and(cb.equal(cs.sub(), 1L), - negate(unknownElementExists(scope, ref, bodyBuilder))), - cb.and(unknownElementExists(scope, ref, bodyBuilder), unknownPredicate())); + yield tri.baseUnlessUnknown(cb.equal(cs.sub(), 1L), + () -> unknownElementExists(scope, ref, bodyBuilder)); } default -> throw new IllegalArgumentException("Unsupported collection operator: " + op); }; @@ -1548,33 +1619,21 @@ private Predicate handleCollectionOperator(String op, List operands, Sc * {@code COUNT(elem) > COUNT(elem WHERE body OR NOT body)} holds iff at least one element * is UNKNOWN, including mixed collections where sibling elements are determined * true/false. Both COUNTs never yield NULL, so the comparison itself is two-valued and - * safe to {@link #negate}. The body is translated fresh per occurrence (stateful - * negation — see {@link #negate}). + * safe to negate through {@link TriPredicate#not}. The body is supplied to + * {@link TriPredicate#determined} as a Supplier and translated fresh per occurrence + * (stateful negation — see {@link TriPredicate#not}). */ private Predicate unknownElementExists(Scope scope, Scope.ResolvedRelation ref, SubqueryBodyBuilder bodyBuilder) { ChainSubquery total = countSubquery(scope, ref); ChainSubquery determined = countSubquery(scope, ref); - determined.sub().where(cb.or( - bodyBuilder.build(determined.sub(), determined.tailJoin(), determined.rebasedOuter()), - negate(bodyBuilder.build(determined.sub(), determined.tailJoin(), determined.rebasedOuter())))); + determined.sub().where(tri.determined(() -> bodyBuilder.build( + determined.sub(), determined.tailJoin(), determined.rebasedOuter()))); return cb.greaterThan(total.sub(), determined.sub()); } - /** - * A constant SQL UNKNOWN: {@code 1 = NULL} (validated by the unknownBooleanConstantProbe - * unit test against Hibernate 6). Composes by three-valued logic exactly like CEL error - * absorption: {@code x AND UNKNOWN} is FALSE when x is FALSE (a false witness absorbs the - * error) and UNKNOWN when x is TRUE; {@code x OR UNKNOWN} is TRUE when x is TRUE and - * UNKNOWN when x is FALSE. Its negation is UNKNOWN as well, so predicates carrying it - * stay excluded under both polarities. - */ - private Predicate unknownPredicate() { - return cb.equal(cb.literal(1), cb.nullLiteral(Integer.class)); - } - @FunctionalInterface private interface SubqueryBodyBuilder { /** diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/TriPredicate.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/TriPredicate.java new file mode 100644 index 00000000..47afa744 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/TriPredicate.java @@ -0,0 +1,159 @@ +package dev.cerbos.queryplan.springdata; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Predicate; + +import java.util.function.Supplier; + +/** + * The tri-state (three-valued) predicate algebra behind the adapter's error→deny contract. + * + *

Semantic contract: a CEL evaluation error (e.g. touching a missing attribute) makes Cerbos + * DENY the check. In SQL, a predicate whose CEL counterpart would error must therefore evaluate + * UNKNOWN — never FALSE — so that {@code NOT(...)} cannot flip a NULL-derived row back to + * included ({@code NOT(UNKNOWN) = UNKNOWN}, but {@code NOT(FALSE) = TRUE} would leak rows the + * PDP denies). Every composition in this class preserves that property. + * + *

Two structural invariants are owned here rather than by comment discipline at call sites: + *

    + *
  • Junction barrier: ALL logical negation goes through {@link #not}, which wraps the + * operand in a single-element conjunction before negating. Hibernate 6's SQM negation is + * stateful for comparison predicates: {@code cb.not(cb.not(p))} stays negated instead of + * toggling back (verified against Hibernate 6.6.18 — a double-negated {@code eq} still + * renders a single {@code NOT}). The barrier gives each {@code not} a fresh node to negate, + * so nested negations compose correctly. {@code cb.not} must not be called anywhere else + * in the adapter.
  • + *
  • Fresh predicate per polarity: a Hibernate {@code Predicate} node must NEVER be + * shared between a positive and a negated occurrence (stateful negation again). Every + * method here that consumes an input in more than one polarity takes a + * {@link Supplier Supplier<Predicate>} — not a pre-built {@code Predicate} — and + * invokes it once per occurrence, so fresh-per-occurrence is enforced by the signature. + * Methods that consume an input exactly once accept a plain {@code Predicate}.
  • + *
+ * + *

Design note — rejected alternative: a combinator/wrapper style + * ({@code TriPredicate.of(cb, supplier).and(...).orUnknownWhen(...).toPredicate()}) was sketched + * first. It was rejected because it needs a larger interface (a wrapper type plus generic + * of/and/or/not/orUnknownWhen/terminal combinators) while still leaving the macro truth tables — + * the actual invariant-bearing knowledge — assembled at the adapter call sites, which is exactly + * the comment-discipline failure mode this module exists to remove. The chosen shape names each + * truth table once, here, and callers cannot re-derive a wrong one. + */ +final class TriPredicate { + + private final CriteriaBuilder cb; + + TriPredicate(CriteriaBuilder cb) { + this.cb = cb; + } + + /** + * A constant SQL UNKNOWN: {@code 1 = NULL}. Composes by three-valued logic exactly like CEL + * error absorption: {@code x AND UNKNOWN} is FALSE when x is FALSE (a false witness absorbs + * the error) and UNKNOWN when x is TRUE; {@code x OR UNKNOWN} is TRUE when x is TRUE and + * UNKNOWN when x is FALSE. Its negation is UNKNOWN as well, so predicates carrying it stay + * excluded under both polarities. + */ + Predicate unknown() { + return cb.equal(cb.literal(1), cb.nullLiteral(Integer.class)); + } + + /** + * Junction-barriered logical negation: {@code cb.not(cb.and(p))}. The single-element + * conjunction is the barrier that makes nested negations compose under Hibernate 6's + * stateful SQM negation (see the class Javadoc). {@code p} is consumed in exactly one + * (negated) polarity, so a pre-built node is safe here — callers must not reuse it + * positively elsewhere. + */ + Predicate not(Predicate p) { + return cb.not(cb.and(p)); + } + + /** + * {@code body OR NOT body} — TRUE iff {@code body} is determined (two-valued), UNKNOWN iff + * {@code body} is UNKNOWN. This is the determinedness test the unknown-element COUNT probes + * filter on. {@code body} is built twice (once per polarity). + */ + Predicate determined(Supplier body) { + return cb.or(body.get(), not(body.get())); + } + + /** + * The CEL ternary {@code if(c, a, b)} as a pure predicate: + * + *

{@code (c AND then) OR (NOT c AND else) OR NOT(c OR NOT c)}
+ * + * The third arm is FALSE when {@code c} is known (no effect on the OR) and UNKNOWN when + * {@code c} is UNKNOWN, driving the whole predicate to UNKNOWN so the row is excluded under + * BOTH polarities — matching the CEL evaluation error (deny) on a null/missing condition. + * The two branch arms alone are not enough: with both branch predicates false the predicate + * would collapse to FALSE, which {@code NOT} flips to TRUE and leaks rows the PDP denies. + * The condition is built fresh four times (two polarities, plus both polarities again in the + * third arm); each branch is built exactly once. + */ + Predicate ternary(Supplier condition, + Supplier thenBranch, + Supplier elseBranch) { + return cb.or( + cb.and(condition.get(), thenBranch.get()), + cb.and(not(condition.get()), elseBranch.get()), + unknownWhenUnknown(condition)); + } + + /** + * UNKNOWN exactly when {@code condition} is UNKNOWN, FALSE when it is known: + * {@code NOT(c OR NOT c)}. Truth table: condition TRUE → {@code NOT(TRUE OR FALSE)} = + * FALSE; condition FALSE → {@code NOT(FALSE OR TRUE)} = FALSE; condition UNKNOWN → + * {@code NOT(UNKNOWN OR UNKNOWN)} = UNKNOWN. + */ + private Predicate unknownWhenUnknown(Supplier condition) { + return not(determined(condition)); + } + + /** + * OR with error absorption — the {@code exists}/{@code filter}/{@code except} table: + * {@code trueWitness OR (unknownWitness AND UNKNOWN)}. + *
    + *
  • witness TRUE → TRUE (a true witness absorbs any unknown sibling)
  • + *
  • witness FALSE, unknown witness TRUE → UNKNOWN (deny)
  • + *
  • witness FALSE, unknown witness FALSE → FALSE
  • + *
+ * Both inputs are consumed once, positively; {@code unknownWitness} must be a two-valued + * detector (e.g. an EXISTS or a COUNT comparison). + */ + Predicate anyTrueOrUnknown(Predicate trueWitness, Predicate unknownWitness) { + return cb.or(trueWitness, cb.and(unknownWitness, unknown())); + } + + /** + * AND with error absorption — the {@code all} table: + * {@code NOT falseWitness AND (NOT unknownWitness OR UNKNOWN)}. + *
    + *
  • false witness TRUE → FALSE (a false witness absorbs any unknown sibling)
  • + *
  • false witness FALSE, unknown witness TRUE → UNKNOWN (deny)
  • + *
  • false witness FALSE, unknown witness FALSE → TRUE
  • + *
+ * Both inputs are consumed once, each in a single (negated) polarity; both must be + * two-valued detectors. + */ + Predicate allTrueOrUnknown(Predicate falseWitness, Predicate unknownWitness) { + return cb.and(not(falseWitness), cb.or(not(unknownWitness), unknown())); + } + + /** + * Strict guard — the {@code exists_one} / {@code size(filter(...))} / + * map-intersection table: {@code (base AND NOT unknownWitness) OR (unknownWitness AND + * UNKNOWN)}. No absorption: ANY unknown witness poisons the result. + *
    + *
  • unknown witness TRUE → UNKNOWN (deny), even when {@code base} is TRUE
  • + *
  • unknown witness FALSE → {@code base}
  • + *
+ * {@code unknownWitness} is consumed in both polarities and therefore built fresh twice; it + * must be a two-valued detector. {@code base} is consumed once, positively. + */ + Predicate baseUnlessUnknown(Predicate base, Supplier unknownWitness) { + return cb.or( + cb.and(base, not(unknownWitness.get())), + cb.and(unknownWitness.get(), unknown())); + } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index c9aa8ced..0a6f5f6b 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -930,8 +930,8 @@ void kitchensink() { } // -- DeMorgan / negated operator wrappers (PR #222) -- - // The adapter handles `not` by wrapping `cb.not(...)` around the inner predicate; - // every supported inner operator composes without source changes. + // The adapter handles `not` through TriPredicate.not() (junction-barriered negation) + // around the inner predicate; every supported inner operator composes without source changes. @Nested class DeMorganNegation { diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index aa1d22a3..1aa94756 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -1072,9 +1072,11 @@ private Operand existsOnePublic() { } /** - * Probe for the UNKNOWN boolean constant the macro translations compose with: - * {@code 1 = NULL} must render as a genuinely UNKNOWN predicate in Hibernate 6 — - * matching no rows under EITHER polarity (NOT(UNKNOWN) = UNKNOWN). + * Probe for the UNKNOWN boolean constant the macro translations compose with: the + * predicate {@link TriPredicate#unknown()} produces must render as a genuinely UNKNOWN + * predicate in Hibernate 6 — matching no rows under EITHER polarity + * (NOT(UNKNOWN) = UNKNOWN). Asserted directly against the module seam the adapter + * composes through; the full algebra truth tables live in {@link TriPredicateTest}. */ @Test void unknownBooleanConstantProbe() { @@ -1082,16 +1084,16 @@ void unknownBooleanConstantProbe() { EntityManager em = emf.createEntityManager(); try { CriteriaBuilder cb = em.getCriteriaBuilder(); + TriPredicate tri = new TriPredicate(cb); CriteriaQuery positive = cb.createQuery(Long.class); positive.select(cb.count(positive.from(ResourceEntity.class))); - positive.where(cb.equal(cb.literal(1), cb.nullLiteral(Integer.class))); + positive.where(tri.unknown()); assertEquals(0, em.createQuery(positive).getSingleResult().intValue()); - // Junction-barriered negation, mirroring the adapter's negate() helper. + // Junction-barriered negation — the module's own not(). CriteriaQuery negated = cb.createQuery(Long.class); negated.select(cb.count(negated.from(ResourceEntity.class))); - negated.where(cb.not(cb.and( - cb.equal(cb.literal(1), cb.nullLiteral(Integer.class))))); + negated.where(tri.not(tri.unknown())); assertEquals(0, em.createQuery(negated).getSingleResult().intValue()); } finally { em.close(); diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/TriPredicateTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/TriPredicateTest.java new file mode 100644 index 00000000..a2e361b4 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/TriPredicateTest.java @@ -0,0 +1,295 @@ +package dev.cerbos.queryplan.springdata; + +import dev.cerbos.queryplan.springdata.testmodel.ResourceEntity; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pins the {@link TriPredicate} algebra directly at its own seam, against Hibernate 6 + H2 — + * the same fixture pattern as {@link SpringDataQueryPlanAdapterTest} but with tiny hand-built + * predicates instead of query plans. + * + *

One row is seeded with {@code aString = "seed"} and {@code aOptionalString = NULL}, giving + * three primitive predicates over it: a known-TRUE one, a known-FALSE one, and an UNKNOWN one + * (a comparison against the NULL column). Every truth table is asserted under BOTH polarities: + * a count of 0 for the positive query AND 0 for the {@code tri.not(...)}-wrapped query is the + * signature of UNKNOWN (excluded either way — the error→deny contract), while FALSE flips to 1 + * under negation. + */ +class TriPredicateTest { + + private static final String SEED_ID = "tri-predicate-seed"; + + private static EntityManagerFactory emf; + + @BeforeAll + static void setUp() { + emf = Persistence.createEntityManagerFactory("test-pu"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ResourceEntity seed = new ResourceEntity(SEED_ID); + seed.setaString("seed"); + seed.setaOptionalString(null); + em.persist(seed); + em.getTransaction().commit(); + em.close(); + } + + @AfterAll + static void tearDown() { + if (emf == null) { + return; + } + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ResourceEntity seed = em.find(ResourceEntity.class, SEED_ID); + if (seed != null) { + em.remove(seed); + } + em.getTransaction().commit(); + em.close(); + emf.close(); + } + + /** Builds the predicate under test from a fresh (cb, tri, root) triple. */ + @FunctionalInterface + private interface PredicateFactory { + Predicate build(CriteriaBuilder cb, TriPredicate tri, Root root); + } + + /** Rows matched by the factory's predicate: 1 = TRUE for the seed row, 0 = FALSE or UNKNOWN. */ + private static int count(PredicateFactory factory) { + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + TriPredicate tri = new TriPredicate(cb); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(ResourceEntity.class); + cq.select(cb.count(root)); + cq.where(factory.build(cb, tri, root)); + return em.createQuery(cq).getSingleResult().intValue(); + } finally { + em.close(); + } + } + + /** {@link #count} of the factory's predicate under {@code tri.not(...)} — the other polarity. */ + private static int countNegated(PredicateFactory factory) { + return count((cb, tri, root) -> tri.not(factory.build(cb, tri, root))); + } + + // -- primitive predicates over the seed row -- + + /** TRUE for the seed row. */ + private static Predicate knownTrue(CriteriaBuilder cb, Root root) { + return cb.equal(root.get("aString"), "seed"); + } + + /** FALSE for the seed row. */ + private static Predicate knownFalse(CriteriaBuilder cb, Root root) { + return cb.equal(root.get("aString"), "something-else"); + } + + /** UNKNOWN for the seed row: comparison against its NULL column. */ + private static Predicate unknownLeaf(CriteriaBuilder cb, Root root) { + return cb.equal(root.get("aOptionalString"), "anything"); + } + + // -- unknown(): the UNKNOWN constant -- + + @Test + void unknownConstantExcludedUnderBothPolarities() { + assertEquals(0, count((cb, tri, root) -> tri.unknown())); + assertEquals(0, countNegated((cb, tri, root) -> tri.unknown())); + } + + @Test + void nullDerivedLeafExcludedUnderBothPolarities() { + // Control for the fixture itself: a comparison against the NULL column really is + // UNKNOWN, not FALSE — the raw material every guarded composition is built for. + assertEquals(0, count((cb, tri, root) -> unknownLeaf(cb, root))); + assertEquals(0, countNegated((cb, tri, root) -> unknownLeaf(cb, root))); + } + + // -- not(): the junction barrier -- + + @Test + void notFlipsKnownPredicates() { + assertEquals(0, count((cb, tri, root) -> tri.not(knownTrue(cb, root)))); + assertEquals(1, count((cb, tri, root) -> tri.not(knownFalse(cb, root)))); + } + + @Test + void doubleNegationComposesThroughJunctionBarrier() { + // Hibernate 6's raw cb.not(cb.not(eq)) collapses to a single NOT; the barrier must + // restore boolean algebra: NOT NOT p = p, NOT NOT NOT p = NOT p. + assertEquals(1, count((cb, tri, root) -> tri.not(tri.not(knownTrue(cb, root))))); + assertEquals(0, count((cb, tri, root) -> tri.not(tri.not(knownFalse(cb, root))))); + assertEquals(0, count((cb, tri, root) -> tri.not(tri.not(tri.not(knownTrue(cb, root)))))); + } + + // -- determined(): the two-polarity determinedness probe -- + + @Test + void determinedIsTrueForKnownBodiesAndUnknownForUnknownBodies() { + assertEquals(1, count((cb, tri, root) -> tri.determined(() -> knownTrue(cb, root)))); + assertEquals(1, count((cb, tri, root) -> tri.determined(() -> knownFalse(cb, root)))); + // UNKNOWN body: UNKNOWN OR NOT UNKNOWN = UNKNOWN — excluded under both polarities. + assertEquals(0, count((cb, tri, root) -> tri.determined(() -> unknownLeaf(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> tri.determined(() -> unknownLeaf(cb, root)))); + } + + // -- ternary(): condition-unknown arm -- + + @Test + void ternaryWithKnownConditionSelectsTheBranch() { + // TRUE condition → then-branch decides. + assertEquals(1, count((cb, tri, root) -> tri.ternary( + () -> knownTrue(cb, root), () -> knownTrue(cb, root), () -> knownFalse(cb, root)))); + assertEquals(0, count((cb, tri, root) -> tri.ternary( + () -> knownTrue(cb, root), () -> knownFalse(cb, root), () -> knownTrue(cb, root)))); + assertEquals(1, countNegated((cb, tri, root) -> tri.ternary( + () -> knownTrue(cb, root), () -> knownFalse(cb, root), () -> knownTrue(cb, root)))); + // FALSE condition → else-branch decides. + assertEquals(1, count((cb, tri, root) -> tri.ternary( + () -> knownFalse(cb, root), () -> knownFalse(cb, root), () -> knownTrue(cb, root)))); + assertEquals(0, count((cb, tri, root) -> tri.ternary( + () -> knownFalse(cb, root), () -> knownTrue(cb, root), () -> knownFalse(cb, root)))); + assertEquals(1, countNegated((cb, tri, root) -> tri.ternary( + () -> knownFalse(cb, root), () -> knownTrue(cb, root), () -> knownFalse(cb, root)))); + } + + @Test + void ternaryWithUnknownConditionIsUnknownNotFalse() { + // Both branches TRUE, condition UNKNOWN: the two branch arms alone would collapse to + // FALSE (leaking the row under NOT); the third arm must force UNKNOWN — excluded under + // BOTH polarities. + assertEquals(0, count((cb, tri, root) -> tri.ternary( + () -> unknownLeaf(cb, root), () -> knownTrue(cb, root), () -> knownTrue(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> tri.ternary( + () -> unknownLeaf(cb, root), () -> knownTrue(cb, root), () -> knownTrue(cb, root)))); + } + + // -- anyTrueOrUnknown(): the exists/filter/except absorption table -- + + @Test + void anyTrueOrUnknownTruthTable() { + // True witness absorbs an unknown sibling → TRUE. + assertEquals(1, count((cb, tri, root) -> + tri.anyTrueOrUnknown(knownTrue(cb, root), knownTrue(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> + tri.anyTrueOrUnknown(knownTrue(cb, root), knownTrue(cb, root)))); + // No true witness, unknown witness → UNKNOWN (deny) under both polarities. + assertEquals(0, count((cb, tri, root) -> + tri.anyTrueOrUnknown(knownFalse(cb, root), knownTrue(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> + tri.anyTrueOrUnknown(knownFalse(cb, root), knownTrue(cb, root)))); + // No true witness, no unknown witness → plain FALSE (flips under NOT). + assertEquals(0, count((cb, tri, root) -> + tri.anyTrueOrUnknown(knownFalse(cb, root), knownFalse(cb, root)))); + assertEquals(1, countNegated((cb, tri, root) -> + tri.anyTrueOrUnknown(knownFalse(cb, root), knownFalse(cb, root)))); + // True witness, no unknown → plain TRUE. + assertEquals(1, count((cb, tri, root) -> + tri.anyTrueOrUnknown(knownTrue(cb, root), knownFalse(cb, root)))); + } + + // -- allTrueOrUnknown(): the all absorption table -- + + @Test + void allTrueOrUnknownTruthTable() { + // False witness absorbs an unknown sibling → FALSE (flips under NOT). + assertEquals(0, count((cb, tri, root) -> + tri.allTrueOrUnknown(knownTrue(cb, root), knownTrue(cb, root)))); + assertEquals(1, countNegated((cb, tri, root) -> + tri.allTrueOrUnknown(knownTrue(cb, root), knownTrue(cb, root)))); + // No false witness, unknown witness → UNKNOWN (deny) under both polarities. + assertEquals(0, count((cb, tri, root) -> + tri.allTrueOrUnknown(knownFalse(cb, root), knownTrue(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> + tri.allTrueOrUnknown(knownFalse(cb, root), knownTrue(cb, root)))); + // No false witness, no unknown witness → TRUE. + assertEquals(1, count((cb, tri, root) -> + tri.allTrueOrUnknown(knownFalse(cb, root), knownFalse(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> + tri.allTrueOrUnknown(knownFalse(cb, root), knownFalse(cb, root)))); + } + + // -- baseUnlessUnknown(): the exists_one / size(filter) / map-intersection strict table -- + + @Test + void baseUnlessUnknownWithFalseBaseAndUnknownWitnessIsUnknownNotFalse() { + // The load-bearing case: base FALSE + unknown witness must be UNKNOWN, not FALSE — + // otherwise NOT(...) would include exactly the rows the PDP denies. + assertEquals(0, count((cb, tri, root) -> + tri.baseUnlessUnknown(knownFalse(cb, root), () -> knownTrue(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> + tri.baseUnlessUnknown(knownFalse(cb, root), () -> knownTrue(cb, root)))); + } + + @Test + void baseUnlessUnknownTruthTable() { + // No unknown witness → base passes through, both polarities. + assertEquals(1, count((cb, tri, root) -> + tri.baseUnlessUnknown(knownTrue(cb, root), () -> knownFalse(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> + tri.baseUnlessUnknown(knownTrue(cb, root), () -> knownFalse(cb, root)))); + assertEquals(0, count((cb, tri, root) -> + tri.baseUnlessUnknown(knownFalse(cb, root), () -> knownFalse(cb, root)))); + assertEquals(1, countNegated((cb, tri, root) -> + tri.baseUnlessUnknown(knownFalse(cb, root), () -> knownFalse(cb, root)))); + // Strictness: an unknown witness poisons even a TRUE base — no absorption. + assertEquals(0, count((cb, tri, root) -> + tri.baseUnlessUnknown(knownTrue(cb, root), () -> knownTrue(cb, root)))); + assertEquals(0, countNegated((cb, tri, root) -> + tri.baseUnlessUnknown(knownTrue(cb, root), () -> knownTrue(cb, root)))); + } + + // -- structural invariant: multi-polarity inputs are rebuilt per occurrence -- + + @Test + void multiPolarityInputsAreBuiltFreshPerOccurrence() { + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + TriPredicate tri = new TriPredicate(cb); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(ResourceEntity.class); + + AtomicInteger calls = new AtomicInteger(); + Supplier fresh = () -> { + calls.incrementAndGet(); + return cb.equal(root.get("aString"), "seed"); + }; + + // determined: body appears in two polarities → built exactly twice. + tri.determined(fresh); + assertEquals(2, calls.getAndSet(0)); + + // baseUnlessUnknown: unknown witness appears in two polarities → built exactly twice. + tri.baseUnlessUnknown(cb.conjunction(), fresh); + assertEquals(2, calls.getAndSet(0)); + + // ternary: condition appears twice directly plus twice in the unknown arm → four. + tri.ternary(fresh, cb::conjunction, cb::conjunction); + assertEquals(4, calls.getAndSet(0)); + } finally { + em.close(); + } + } +}