diff --git a/.github/workflows/spring-data.yaml b/.github/workflows/spring-data.yaml new file mode 100644 index 00000000..05b275e0 --- /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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup JDK + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.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/policies/resource.yaml b/policies/resource.yaml index fed4c134..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 @@ -942,4 +960,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/.gitignore b/spring-data/.gitignore new file mode 100644 index 00000000..32ce3093 --- /dev/null +++ b/spring-data/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +bin/ +.idea/ +*.iml 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/Dockerfile b/spring-data/Dockerfile new file mode 100644 index 00000000..933fb0d0 --- /dev/null +++ b/spring-data/Dockerfile @@ -0,0 +1,8 @@ +FROM gradle:8.12-jdk17 AS build +WORKDIR /app +COPY build.gradle.kts settings.gradle.kts ./ +COPY src ./src +# 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 new file mode 100644 index 00000000..4d7687b0 --- /dev/null +++ b/spring-data/README.md @@ -0,0 +1,382 @@ +# 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` | `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: + +```java +Specification own = + (root, query, cb) -> cb.like(root.get("name"), "Smith%"); + +Page 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 — 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` | +| `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) | +| 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))` | +| `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` | +| `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`: + +```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); +``` + +## 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 | +|-------------------------------------------------|---------------------------------------------------|-------| +| `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. | +| `eq(map(...), [...])` | `R.attr.tags.map(t, t.id) == ["tag1", "tag2"]` | Use `hasIntersection(map(...), [...])` instead. | + +## 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. + +### 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 `'%%'`). + +### 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 +`(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 +`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. + +### 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: + +```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. 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) + +[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..adfd5c8c --- /dev/null +++ b/spring-data/build.gradle.kts @@ -0,0 +1,59 @@ +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") + // 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 + // 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") + 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/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 diff --git a/spring-data/scripts/run-e2e.sh b/spring-data/scripts/run-e2e.sh new file mode 100755 index 00000000..3643d4f4 --- /dev/null +++ b/spring-data/scripts/run-e2e.sh @@ -0,0 +1,80 @@ +#!/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" + # 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}" \ + -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/HierarchyTranslator.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java new file mode 100644 index 00000000..18ad535f --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/HierarchyTranslator.java @@ -0,0 +1,323 @@ +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 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 — 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) { + 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/OperatorFunction.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java new file mode 100644 index 00000000..375a8c6c --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java @@ -0,0 +1,41 @@ +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. + * + *

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}). + * + *

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. + * + *

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, 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 { + Predicate apply(CriteriaBuilder cb, Expression field, Object value); +} 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..26fb8423 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/PlanValues.java @@ -0,0 +1,107 @@ +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(); + // 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; + } + 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/Result.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java new file mode 100644 index 00000000..983f16a8 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java @@ -0,0 +1,61 @@ +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} – {@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(); + + record AlwaysAllowed() implements Result { + @Override + public Specification toSpecification() { + // 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; + } + } + + record AlwaysDenied() implements Result { + @Override + public Specification toSpecification() { + return (root, query, cb) -> cb.disjunction(); + } + } + + /** + * 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() { + return specification; + } + } +} + 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..31102555 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Scope.java @@ -0,0 +1,299 @@ +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); + + /** + * 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); + } + + static Scope lambda(From from, AbstractQuery parentQuery, + AttributeMapping.Relation relation, String lambdaVar, Scope outer) { + return new LambdaScope(from, parentQuery, relation, lambdaVar, 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 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) { + 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(), + 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 + 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); + } + + @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; + } + } + + /** + * 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); + } + + @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); + } + } + + /** + * 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 new file mode 100644 index 00000000..76d0355b --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -0,0 +1,1716 @@ +package dev.cerbos.queryplan.springdata; + +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.CriteriaBuilder; +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 com.google.protobuf.Value; + +import java.util.List; +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 + * {@link org.springframework.data.jpa.domain.Specification} that can be executed by any + * {@code JpaSpecificationExecutor}. + */ +public final class SpringDataQueryPlanAdapter { + + 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, 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, 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 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); + } + + 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); + return applyLeaf("eq", 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 tri.not(traverse(operands.get(0), scope)); + } + case "exists", "exists_one", "all", "except", "filter" -> + handleCollectionOperator(op, 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 "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 -> comparisons.translate(op, operands, scope); + }; + } + + /** + * 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}. + * + *

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 + * the constant-receiver case of {@link ComparisonTranslator#dispatch}. + */ + 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 (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); + } + + 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; + }; + } + } + + /** + * 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)); + } + + /** + * 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); + } + 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); + }; + } + + /** + * 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. + * + *

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. + * + *

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 final class ComparisonTranslator { + + /** + * 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( + nb.op() + " requires exactly 2 operands, got " + nb.operands().size()); + } + return dispatch(nb.op(), + resolve(nb.operands().get(0)), + resolve(nb.operands().get(1)), + nb.operands(), scope); + } + + // -- 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. + */ + private static final Set COMPARISON_OPS = + Set.of("eq", "ne", "lt", "gt", "le", "ge"); + + /** + * 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 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); + + 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); + } + + return tri.ternary( + () -> traverse(condition, scope), + () -> branchTranslator.apply(thenBranch), + () -> branchTranslator.apply(elseBranch)); + } + + /** + * 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); + } + + /** + * 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; + } + + // -- 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()); + } + } + + /** 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 -> { + PlanResourcesFilter.Expression e = o.getExpression(); + String exprOp = e.getOperator(); + if (!ARITHMETIC_OPS.contains(exprOp)) { + yield new Resolved.Opaque(); + } + 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 -> new Resolved.Opaque(); + }; + } + + /** 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())); + } + + /** 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; + } + + // -- dispatch on the resolved pair -- + + /** + * 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()); + } + + // 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. + } + + 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); + } + // 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); + } + + if (left instanceof Resolved.Field a && right instanceof Resolved.Field b) { + return fieldToFieldComparison(op, a.variable(), b.variable(), scope); + } + + // 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); + } + + throw leafOperandError(op, operands); + } + + /** `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()); + + 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); + } + + /** + * 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); + } + + /** + * {@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 + ")"); + } + + /** + * 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); + } + + /** + * 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) 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); + }; + } + + /** + * 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); + }; + } + + /** + * {@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, '\\')); + } + + // -- 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); + } + + /** + * 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 {} + } + + /** + * 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()); + } + } + + /** + * 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); + } + + // -- 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 = 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); + } + } + 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 strict table as + // exists_one: TriPredicate.baseUnlessUnknown. + return tri.baseUnlessUnknown(base, + () -> unknownElementExists(scope, ref, bodyBuilder)); + } + + /** 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); + }; + } + } + // -- end ComparisonTranslator -- + + // -- 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 = PlanValues.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); + 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"); + } + // 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()); + + Scope.ResolvedRelation relRef = scope.resolveRelation(var); + if (relRef != null) { + return collectionContainsAny(scope, relRef, asList(val)); + } + + Path path = scope.resolvePath(var); + return withOverride("in", path, val, () -> { + if (val instanceof List list) { + if (list.isEmpty()) { + return cb.disjunction(); + } + return path.in(list); + } + return cb.equal(path, val); + }); + } + + // -- hasIntersection -- + + private Predicate handleHasIntersection(List rawOperands, Scope scope) { + if (rawOperands.size() != 2) { + throw new IllegalArgumentException("hasIntersection requires exactly 2 operands"); + } + // 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. 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 = PlanValues.protoValueToJava(second.getValue()); + List values = asList(val); + + 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 ()`. + if (values.isEmpty()) { + return cb.disjunction(); + } + 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 = PlanValues.protoValueToJava(second.getValue()); + 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) { + // hasIntersection(map(...), []) is always false; short-circuit before the subquery. + if (values.isEmpty()) { + return cb.disjunction(); + } + + 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"); + } + String collectionVar = collectionOperand.getVariable(); + + 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(), lambda.varName()); + + // 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); + } + // 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)))); + } + + 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(scope, ref, (sub, tailJoin, rebased) -> { + Path field = Scope.memberPath(tailJoin, ref.tail(), null); + if (values.size() == 1) { + return cb.equal(field, values.get(0)); + } + return field.in(values); + }); + } + + // -- 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) 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 + * {@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) { + 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(); + // 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); + } + + 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 TriPredicate.not()). + SubqueryBodyBuilder bodyBuilder = (sub, tailJoin, rebased) -> traverse(body, + Scope.lambda(tailJoin, sub, ref.tail(), lambdaVarName, rebased)); + SubqueryBodyBuilder negatedBodyBuilder = (sub, tailJoin, rebased) -> + tri.not(bodyBuilder.build(sub, tailJoin, rebased)); + + return switch (op) { + // exists (and filter): OR with error absorption — TriPredicate.anyTrueOrUnknown. + case "exists", "filter" -> tri.anyTrueOrUnknown( + existsSubquery(scope, ref, bodyBuilder), + 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" -> 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), + 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 tri.baseUnlessUnknown(cb.equal(cs.sub(), 1L), + () -> unknownElementExists(scope, ref, bodyBuilder)); + } + 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 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(tri.determined(() -> bodyBuilder.build( + determined.sub(), determined.tailJoin(), determined.rebasedOuter()))); + + return cb.greaterThan(total.sub(), determined.sub()); + } + + @FunctionalInterface + private interface SubqueryBodyBuilder { + /** + * @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, Join tailJoin, Scope rebasedOuter); + } + + /** 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) { + return sub.correlate(r); + } + if (outerFrom instanceof Join j) { + return sub.correlate((Join) j); + } + throw new IllegalArgumentException("Cannot correlate from non-Root, non-Join scope: " + outerFrom); + } + + /** + * 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 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); + } + + /** 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); + 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/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/AdversarialConformanceTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java new file mode 100644 index 00000000..697423ad --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/AdversarialConformanceTest.java @@ -0,0 +1,387 @@ +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.CsvSource; +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")), + // 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( + "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") + )) + ))), + // 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") + ))) + ); + + 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")), + // 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()), + // 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()), + // 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; + + @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()); + r.setCreatedBy(isoFor(s)); + 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("createdBy", AttributeValue.stringValue(isoFor(s))) + .withAttribute("obj", AttributeValue.mapValue(Map.of( + "inner", AttributeValue.stringValue(s.aString())))) + .withAttribute("tags", AttributeValue.listValue(s.tags().stream() + .map(AdversarialConformanceTest::asTagAttribute) + .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())); + } + // 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)) + .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", + "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", + "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); + List filtered = adapterFilteredIds(action); + assertEquals(oracle, filtered, + "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}") + @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 = 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, + // 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/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..0a6f5f6b --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -0,0 +1,1229 @@ +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; +import static org.junit.jupiter.api.Assertions.assertFalse; +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. + * + *

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")) + ); + + // 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( + "name", AttributeMapping.field("name"), + "subCategories", AttributeMapping.relation("subCategories", Map.of( + "name", AttributeMapping.field("name"), + "labels", AttributeMapping.relation("labels", Map.of( + "name", AttributeMapping.field("name") + )) + )) + ))) + ); + + // 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 { + 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; + } + } + + // 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) + .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(); + + // 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(); + } + + @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.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"); + 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.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"); + 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.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"); + 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); + } + + /** + * 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 + 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)); + } + + @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"); + // 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); + } + + 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 -- + // 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 + // 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)); + } + } + + // -- 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)); + } + } + + // -- DeMorgan / negated operator wrappers (PR #222) -- + // 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 { + + @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) -- + // `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 (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. + + @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 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 arithSub() { + // aNumber - 1.0 < 2.0 → aNumber < 3 → r1 (1), r2 (2). + assertEquals(List.of("1", "2"), run("arith-sub")); + } + + @Test + void arithMult() { + // aNumber * 2.0 > 2.0 → aNumber > 1 → r2, r3. + assertEquals(List.of("2", "3"), run("arith-mult")); + } + + @Test + 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"); + } + + @Test + void matchesRegexThrows() { + assertActionThrows("matches-regex", FIELD_MAP, "Unsupported operator", "matches"); + } + + @Test + void indexListThrows() { + assertActionThrows("index-list", FIELD_MAP, "index"); + } + + @Test + void convertStringThrows() { + assertActionThrows("convert-string", FIELD_MAP, "string"); + } + + @Test + void convertDoubleThrows() { + assertActionThrows("convert-double", FIELD_MAP, "double"); + } + + @Test + void convertIntThrows() { + assertActionThrows("convert-int", FIELD_MAP, "int"); + } + + @Test + 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 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")); + } + } + + // -- 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 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 + 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 with a hint at the supported shape. + @Test + void mapComparedToLiteralListThrows() { + assertActionThrows("map-compared", NESTED_FIELD_MAP, + "map(...)", "hasIntersection"); + } + + @Test + 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")); + } + } + + // -- 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 { + + // 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 new file mode 100644 index 00000000..1aa94756 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -0,0 +1,2501 @@ +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.CategoryEntity; +import dev.cerbos.queryplan.springdata.testmodel.ResourceEntity; +import dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity; + +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.Nested; +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; +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; + +/** + * 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 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)); + } + + /** + * 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. + */ + private static int runCount(Operand condition) { + return runCount(condition, Map.of()); + } + + /** {@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); + 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(); + } + } + + /** 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() { + 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); + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER); + 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); + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER); + 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")))); + } + + @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)))); + } + + // -- 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 ArrayList<>(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"))))); + }); + } + } + + // -- 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 ArrayList<>(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", + 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 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")); + 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("Unsupported 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 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 + 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)))); + } + + // add/sub/mult/div appearing as a comparison operand are supported (double-space SQL + // arithmetic) — see ArithmeticComparisons. Only mod remains rejected. + + @Test + void arithModThrows() { + assertConditionThrows( + exprOp("eq", + exprOp("mod", var("request.resource.attr.aNumber"), nval(2)), + nval(0)), + "mod"); + } + + @Test + void matchesRegexThrows() { + assertConditionThrows( + exprOp("matches", + var("request.resource.attr.aString"), sval("^str.*")), + "Unsupported operator", "matches"); + } + + @Test + void indexListThrows() { + // ownedBy[0] == "user1" — array indexing not supported. + assertConditionThrows( + exprOp("eq", + exprOp("index", var("request.resource.attr.ownedBy"), nval(0)), + sval("user1")), + "index"); + } + + @Test + void convertStringThrows() { + assertConditionThrows( + exprOp("eq", + exprOp("string", var("request.resource.attr.aNumber")), + sval("1")), + "string"); + } + + @Test + void convertDoubleThrows() { + assertConditionThrows( + exprOp("gt", + exprOp("double", var("request.resource.attr.aNumber")), + nval(1.5)), + "double"); + } + + @Test + void convertIntThrows() { + assertConditionThrows( + exprOp("gt", + exprOp("int", var("request.resource.attr.aString")), + nval(0)), + "int"); + } + + @Test + 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"))))); + }); + } + } + + // -- 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 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 fieldToFieldUnsupportedOperatorStillThrows() { + // contains/startsWith/endsWith(var, var) are supported (see FieldToFieldStringMatch); + // anything else without a column-to-column translation keeps the specific message. + assertConditionThrows( + exprOp("matches", + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy")), + "Field-to-field", "matches"); + } + + @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"))); + assertConditionThrows( + exprOp("eq", mapExpr, listOp("tag1", "tag2")), + "map(...)", "hasIntersection"); + } + + @Test + 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")))); + 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)))); + }); + } + } + + // -- 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: 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() { + withResource(new ResourceEntity("null-elem-probe"), () -> { + 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(tri.unknown()); + assertEquals(0, em.createQuery(positive).getSingleResult().intValue()); + + // Junction-barriered negation — the module's own not(). + CriteriaQuery negated = cb.createQuery(Long.class); + negated.select(cb.count(negated.from(ResourceEntity.class))); + negated.where(tri.not(tri.unknown())); + 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 { + + // 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)); + + // 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 + 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"); + } + } + + // -- 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 ArrayList<>(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))); + } + } + + // -- 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. + + @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)))); + // 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"); + }); + + ResourceEntity boolFalse = new ResourceEntity("ternary-const-2"); + boolFalse.setaBool(false); + 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. + 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 + 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)); + }); + } + } + + /** + * 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, + List categories, + 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 (CategoryEntity c : categories) { + CategoryEntity mc = cleanup.find(CategoryEntity.class, c.getId()); + if (mc != null) { + cleanup.remove(mc); + } + } + for (SubCategoryEntity s : subCategories) { + SubCategoryEntity ms = cleanup.find(SubCategoryEntity.class, s.getId()); + if (ms != null) { + cleanup.remove(ms); + } + } + cleanup.getTransaction().commit(); + cleanup.close(); + } + } + + @Test + void existsOverTwoHopChainJoinsThroughIntermediateHop() { + 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(List.of(biz)); + + 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)); + + 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 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(List.of(biz)); + + 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)))); + assertEquals(0, runChainCount(exprOp("in", sval("nope"), var(CHAIN)))); + }); + } + + @Test + void hasIntersectionOverTwoHopChainJoinsThroughIntermediateHop() { + 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(List.of(biz)); + + withCategoryGraph(r, List.of(biz), 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 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(List.of(c1, c2)); + + 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)))); + // 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 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(List.of(biz)); + r.addTag("chain-tag-w1", "public"); + + 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")), + 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 + 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 = PlanValues.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")); + // 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)); + assertEquals(0, runCount(cond, overrides)); + } + + // -- 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")); + }); + } + } + + // -- 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"); + } + + // -- 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 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 + // 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/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(); + } + } +} 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..25ddcfd6 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java @@ -0,0 +1,115 @@ +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; + + @Column(name = "scope") + private String scope; + + @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 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; } + 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..68407233 --- /dev/null +++ b/spring-data/src/test/resources/META-INF/persistence.xml @@ -0,0 +1,54 @@ + + + + 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 + + + + + + + + + + + + + + + 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..6af80da9 --- /dev/null +++ b/spring-data/src/test/resources/adversarial-policy.yaml @@ -0,0 +1,693 @@ +# 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" + + # -- 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' + + # -- 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' + + # ==================== 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'