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/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/Dockerfile b/spring-data/Dockerfile new file mode 100644 index 00000000..0bce1cdf --- /dev/null +++ b/spring-data/Dockerfile @@ -0,0 +1,5 @@ +FROM gradle:8.12-jdk17 AS build +WORKDIR /app +COPY build.gradle.kts settings.gradle.kts ./ +COPY src ./src +RUN gradle build --no-daemon diff --git a/spring-data/README.md b/spring-data/README.md new file mode 100644 index 00000000..63e822de --- /dev/null +++ b/spring-data/README.md @@ -0,0 +1,304 @@ +# cerbos-spring-data + +> **Alpha release — `0.1.0-alpha.1`.** API and operator coverage are stable, but field/relation +> mapping shapes may still change before `1.0`. We'd love feedback while it's still alpha. + +[Cerbos](https://cerbos.dev) query plan adapter for [Spring Data JPA](https://spring.io/projects/spring-data-jpa). Converts a Cerbos `PlanResources` response into a `org.springframework.data.jpa.domain.Specification` you can pass straight to a `JpaSpecificationExecutor`. + +## Install + +Gradle: + +```kotlin +dependencies { + implementation("dev.cerbos:cerbos-spring-data:0.1.0-alpha.1") +} +``` + +Maven: + +```xml + + dev.cerbos + cerbos-spring-data + 0.1.0-alpha.1 + +``` + +You'll also need the Cerbos Java SDK (`dev.cerbos:cerbos-sdk-java`) to call the PDP and Spring Data JPA (`org.springframework.data:spring-data-jpa`). + +## Quick start + +```java +import dev.cerbos.queryplan.springdata.AttributeMapping; +import dev.cerbos.queryplan.springdata.Result; +import dev.cerbos.queryplan.springdata.SpringDataQueryPlanAdapter; +import dev.cerbos.sdk.CerbosBlockingClient; +import dev.cerbos.sdk.builders.Principal; +import dev.cerbos.sdk.builders.Resource; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.Map; + +public interface ContactRepository + extends JpaRepository, JpaSpecificationExecutor {} + +// Map Cerbos resource attributes to JPA paths or relations on your entity: +Map MAPPING = Map.of( + "request.resource.attr.ownerId", AttributeMapping.field("owner.id"), + "request.resource.attr.isPublic", AttributeMapping.field("isPublic"), + "request.resource.attr.department", AttributeMapping.field("department"), + "request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "name", AttributeMapping.field("name") + )) +); + +// 1) Call the PDP for a query plan +var planResult = cerbosClient.plan( + Principal.newInstance("alice", "USER"), + Resource.newInstance("contact"), + "view"); + +// 2) Translate to a Specification +Result result = + SpringDataQueryPlanAdapter.toSpecification(planResult, MAPPING); + +// 3) Execute via your repository +List contacts = contactRepository.findAll(result.toSpecification()); +``` + +`Result.toSpecification()` returns a Specification that captures all three plan kinds, so you don't need to switch on the result kind unless you want to short-circuit the DB hit: + +| Kind | Specification | +|------------------------|----------------------------------------| +| `Result.AlwaysAllowed` | always-true predicate (`1=1`) | +| `Result.AlwaysDenied` | always-false predicate (`1=0`) | +| `Result.Conditional` | the translated predicate tree | + +Compose it with your own filters: + +```java +Specification own = + (root, query, cb) -> cb.like(root.get("name"), "Smith%"); + +List results = contactRepository.findAll( + own.and(result.toSpecification()), pageable); +``` + +## Field mapping + +Map each `request.resource.attr.` to a JPA path or a relation: + +| Helper | Use for | +|---------------------------------------------------------------|---------------------------------------------------------| +| `AttributeMapping.field("aPath")` | Simple column or `@Embedded` dotted path | +| `AttributeMapping.relation("tags")` | `@ElementCollection` (bare values) | +| `AttributeMapping.relation("tags", "name")` | `@OneToMany` collection where the default member field is `name` | +| `AttributeMapping.relation("tags", Map.of("name", field("name")))` | `@OneToMany` with explicit nested field mapping | + +`Field("nested.aBool")` traverses embeddables via JPA `Path.get(...)`. Use it for both simple columns and `@Embedded` paths. + +## Supported operators + +| Cerbos operator | JPA Criteria translation | +|----------------------------------|---------------------------------------------------------------------| +| `and` / `or` / `not` | `cb.and` / `cb.or` / `cb.not` | +| `eq` / `ne` | `cb.equal` / `cb.notEqual` (auto `isNull`/`isNotNull` for `null` RHS) | +| `lt` / `gt` / `le` / `ge` | `cb.lessThan` / `greaterThan` / `lessThanOrEqualTo` / `greaterThanOrEqualTo` | +| `in` | `path.in(values)` or correlated `EXISTS` for collections | +| `contains` / `startsWith` / `endsWith` | `cb.like(...)` with proper `_`/`%`/`\` escaping | +| `isSet(field, true/false)` | `cb.isNotNull` / `cb.isNull` | +| `hasIntersection(coll, [values])` | Correlated `EXISTS` with `IN` | +| `hasIntersection(coll.map(x, x.f), [values])` | Correlated `EXISTS` with projected `IN` | +| `size(coll) > 0` / `>= 1` | Correlated `EXISTS` | +| `size(coll) == 0` / `<= 0` / `< 1`| `NOT EXISTS` | +| `exists(coll, lambda)` | Correlated `EXISTS` with lambda body | +| `exists_one(coll, lambda)` | Correlated `(SELECT COUNT...) = 1` | +| `all(coll, lambda)` | `NOT EXISTS (... AND NOT(body))` | +| `except(coll, lambda)` | Correlated `EXISTS (... AND NOT(body))` | +| `filter(coll, lambda)` | Same as `exists` (filter returns a list — treated as "exists matching") | +| Bare boolean variable | `cb.equal(path, true)` | +| `eq(field, add(const1, const2))` | Constant fold then compare: `cb.equal(field, const1 ⊕ const2)` | +| `eq(value, add(const, field))` | Solve for `field` (string prefix/suffix strip; numeric subtract); unsolvable cases become `1=0` / `1=1` | + +Unsupported operators raise `IllegalArgumentException` — override them with `OperatorFunction`: + +```java +Map overrides = Map.of( + "contains", (cb, field, value) -> + cb.equal(cb.lower(field.as(String.class)), value.toString().toLowerCase()) +); + +Result result = + SpringDataQueryPlanAdapter.toSpecification(planResult, MAPPING, overrides); +``` + +## Not yet supported + +The Criteria-based predicate builder has no shape for these CEL constructs; they +throw `IllegalArgumentException` with a message naming the operator. Override +via `OperatorFunction` when the runtime can express them (e.g. database-specific +SQL fragments), or wait for adapter support. + +| Construct | Example CEL | Notes | +|-------------------------------------------------|---------------------------------------------------|-------| +| Arithmetic (`add`/`sub`/`mult`/`div`/`mod`) | `R.attr.aNumber + 1 > 2` | `add` is supported only as constant folding inside `eq`/`ne`; other arithmetic on document fields requires a column-expression engine the Criteria API doesn't expose. | +| Regex match | `R.attr.aString.matches("^foo.*")` | JPA has no portable regex predicate; override per-dialect (`regexp_like`, `~`, `REGEXP`). | +| List indexing | `R.attr.tags[0] == "x"` | JPA collections are unordered sets — no positional access. | +| Type casts (`int(...)` / `double(...)` / `string(...)`) | `int(R.attr.aString) > 0` | No portable `CAST` in Criteria; override per-dialect. | +| Ternary (`cond ? a : b`) | `(R.attr.aBool ? R.attr.aNumber : 0) > 0` | The CEL planner emits this as `if(cond, then, else)`; JPA Criteria has no `CASE WHEN` value-expression builder. | +| `size(string)` | `size(R.attr.aString) > 0` | Only `size(collection)` (`Relation` mapping) is supported; for strings use `cb.length` via an override. | +| Field-to-field comparison | `R.attr.aString == R.attr.id` | The leaf operator handler requires one variable + one value operand; throws explicitly. | +| `eq(map(...), [...])` | `R.attr.tags.map(t, t.id) == ["tag1", "tag2"]` | Use `hasIntersection(map(...), [...])` instead. | +| `size(filter(...)) N` | `size(R.attr.tags.filter(t, t.name == "x")) > 0` | Use `exists(coll, lambda)` for emptiness; `size()` only accepts a Variable operand. | +| `size(coll) N` for `N > 0` | `size(R.attr.tags) > 5` | Only emptiness checks are supported. | +| Hierarchy operators (`hierarchy-*`) | `hierarchy.overlaps(...)` | Not yet ported from the Prisma adapter; ~250 LoC follow-up. | + +## Gotchas + +Things you're likely to hit when integrating the adapter into a Spring Boot app — see the +[`example/`](example) photo-sharing application for a runnable end-to-end reference. + +### Pin `protobuf-java` to the cerbos-sdk-java's gencode version + +`cerbos-sdk-java` 0.18.0 ships protobuf message classes generated against +`protobuf-java` 4.33.5. If your application classpath ends up with an **older** runtime +— either because you pin it explicitly, or a transitive dependency wins resolution — the +SDK throws on first message decode: + +```text +com.google.protobuf.RuntimeVersion$ProtobufRuntimeVersionException: + Detected incompatible Protobuf Gencode/Runtime versions when loading Principal: + gencode 4.33.5, runtime 4.31.1. Runtime version cannot be older than the linked gencode version. +``` + +Fix — add a direct dependency matching the SDK's gencode: + +```kotlin +implementation("com.google.protobuf:protobuf-java:4.33.5") +``` + +Spring Boot's BOM does not manage `protobuf-java`, so without an explicit pin Gradle's +default conflict resolver picks the highest version on the graph. Pinning makes the +contract explicit and survives BOM upgrades. + +### `@ElementCollection` / `@OneToMany` + `spring.jpa.open-in-view=false` + +Mapping a Cerbos attribute via `AttributeMapping.relation(...)` translates `"x" in tags` +to a correlated `EXISTS` subquery — but the entity collection itself is still lazy by +default. If your controller serializes the entity (or any field traversal happens after +the transaction closes), you'll see: + +```text +HttpMessageNotWritableException: Could not write JSON: + failed to lazily initialize a collection of role: …Photo.tags: could not initialize proxy - no Session +``` + +Pick one: + +- **Eager-fetch** the collection if it's small (`@ElementCollection(fetch = FetchType.EAGER)`). +- **Do the entity-to-DTO mapping inside `@Transactional(readOnly = true)`** so the Hibernate + session is still open while you walk relations. +- **Don't serialize entities** — return a DTO projection instead. + +The adapter itself has no opinion here — this is the same `open-in-view=false` footgun any +JPA app hits — but it's worth flagging because Cerbos plans frequently *do* reference +collection attributes (`tags`, `members`, `categories`), and those are the ones developers +typically forget to fetch. + +### Don't cache the produced `Predicate` + +`Result.Conditional.toSpecification()` returns a Specification whose lambda **rebuilds the +predicate tree against each invocation's `Root`/`CriteriaQuery`**. Spring Data's +`findAll(spec, Pageable)` fires a separate `COUNT` query with its own root, and Hibernate 6 +rejects a `Predicate` produced against a stale root with +`SqlTreeCreationException: Could not locate TableGroup`. Pass the Specification to +repository methods; don't cache the `Predicate` it returns. + +### 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. 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..8d19f56d --- /dev/null +++ b/spring-data/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + java +} + +group = "dev.cerbos" +version = "0.1.0-alpha.1" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("dev.cerbos:cerbos-sdk-java:0.18.0") + implementation("com.google.protobuf:protobuf-java:4.31.1") + // 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..f7b6b9d3 --- /dev/null +++ b/spring-data/scripts/run-e2e.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# End-to-end test runner: starts a real Cerbos PDP container via docker compose, waits for it +# to be healthy, then runs the JUnit suite against it. Mirrors what the Prisma adapter does +# with `cerbos run -- jest`. +# +# Exit status mirrors gradle's. The container is torn down on success, failure, or interrupt. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}/.." + +cleanup() { + echo "==> Tearing down Cerbos PDP container" + docker compose down --volumes --remove-orphans >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +echo "==> Starting Cerbos PDP container (ghcr.io/cerbos/cerbos:latest)" +docker compose up -d --wait cerbos + +CERBOS_HOST="${CERBOS_HOST:-localhost}" +CERBOS_PORT="${CERBOS_PORT:-3593}" +export CERBOS_HOST CERBOS_PORT + +echo "==> Cerbos PDP is healthy at ${CERBOS_HOST}:${CERBOS_PORT}" + +# Stream the PDP's audit/decision logs to a file. Decision-log JSON lines have callId/method +# entries that prove each test really called PlanResources against the live PDP. +AUDIT_LOG="$(mktemp -t cerbos-audit-XXXXXX.log)" +docker compose logs -f cerbos --no-color >"${AUDIT_LOG}" 2>&1 & +AUDIT_PID=$! +trap 'cleanup; kill "${AUDIT_PID}" 2>/dev/null || true; rm -f "${AUDIT_LOG}"' EXIT INT TERM + +echo "==> Running tests against external PDP (audit log → ${AUDIT_LOG})" + +GRADLE_ARGS=(test --rerun-tasks --no-daemon) +if [ "$#" -gt 0 ]; then + GRADLE_ARGS+=("$@") +fi + +if command -v gradle >/dev/null 2>&1; then + gradle "${GRADLE_ARGS[@]}" + TEST_EXIT=$? +else + echo "==> No local gradle found; falling back to gradle:8.12-jdk17 Docker image" + docker run --rm \ + -v "$(pwd)/..":/app \ + --network host \ + -e CERBOS_HOST="${CERBOS_HOST}" \ + -e CERBOS_PORT="${CERBOS_PORT}" \ + -w /app/spring-data \ + gradle:8.12-jdk17 \ + gradle "${GRADLE_ARGS[@]}" + TEST_EXIT=$? +fi + +# Stop following the audit log and summarise what the PDP actually served. +kill "${AUDIT_PID}" 2>/dev/null || true +wait "${AUDIT_PID}" 2>/dev/null || true + +PLAN_COUNT=$(grep -c '"PlanResources"' "${AUDIT_LOG}" 2>/dev/null || true) +CHECK_COUNT=$(grep -c '"CheckResources"' "${AUDIT_LOG}" 2>/dev/null || true) + +echo +echo "==> Cerbos PDP audit summary" +echo " PlanResources calls served: ${PLAN_COUNT:-0}" +echo " CheckResources calls served: ${CHECK_COUNT:-0}" +echo " Audit log archived at: ${AUDIT_LOG}" +echo +echo "==> Sample decision log entries:" +grep -E '"kind"|"action"|"method"|PlanResources' "${AUDIT_LOG}" 2>/dev/null | head -5 || true + +# Don't auto-rm the audit log on success — leave it for inspection. +trap 'cleanup; kill "${AUDIT_PID}" 2>/dev/null || true' EXIT INT TERM + +exit "${TEST_EXIT}" diff --git a/spring-data/settings.gradle.kts b/spring-data/settings.gradle.kts new file mode 100644 index 00000000..16610b69 --- /dev/null +++ b/spring-data/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "cerbos-spring-data" diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/AttributeMapping.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/AttributeMapping.java new file mode 100644 index 00000000..662a7aeb --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/AttributeMapping.java @@ -0,0 +1,31 @@ +package dev.cerbos.queryplan.springdata; + +import java.util.Map; + +public sealed interface AttributeMapping permits AttributeMapping.Field, AttributeMapping.Relation { + + static Field field(String jpaPath) { + return new Field(jpaPath); + } + + static Relation relation(String joinAttribute) { + return new Relation(joinAttribute, null, Map.of()); + } + + static Relation relation(String joinAttribute, String defaultMemberField) { + return new Relation(joinAttribute, defaultMemberField, Map.of()); + } + + static Relation relation(String joinAttribute, Map fields) { + return new Relation(joinAttribute, null, fields); + } + + static Relation relation(String joinAttribute, String defaultMemberField, Map fields) { + return new Relation(joinAttribute, defaultMemberField, fields); + } + + record Field(String jpaPath) implements AttributeMapping {} + + record Relation(String joinAttribute, String defaultMemberField, Map fields) + implements AttributeMapping {} +} diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java new file mode 100644 index 00000000..ef8150fb --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/OperatorFunction.java @@ -0,0 +1,27 @@ +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}). + * + *

Overrides are not consulted for operators that translate to correlated {@code EXISTS} + * subqueries against a {@code Relation} mapping — {@code exists}/{@code exists_one}/{@code all}/ + * {@code except}/{@code filter}, {@code hasIntersection} over a relation, {@code size(...)}, and the + * relation form of {@code in} — because those have no single resolved (field, value) pair. + */ +@FunctionalInterface +public interface OperatorFunction { + Predicate apply(CriteriaBuilder cb, Expression field, Object value); +} diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/Result.java new file mode 100644 index 00000000..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/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java new file mode 100644 index 00000000..8f642010 --- /dev/null +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -0,0 +1,965 @@ +package dev.cerbos.queryplan.springdata; + +import com.google.protobuf.Value; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression.Operand; +import dev.cerbos.api.v1.response.Response.PlanResourcesResponse; +import dev.cerbos.sdk.PlanResourcesResult; + +import jakarta.persistence.criteria.AbstractQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Translates a Cerbos {@code PlanResources} response into a Spring Data JPA + * {@link org.springframework.data.jpa.domain.Specification} that can be executed by any + * {@code JpaSpecificationExecutor}. + */ +public final class SpringDataQueryPlanAdapter { + + // Alias for the deeply-nested protobuf type to avoid collision with jakarta.persistence.criteria.Expression + private static final class PlanExpr { + private PlanExpr() {} + } + + private SpringDataQueryPlanAdapter() {} + + // -- PlanResourcesResult overloads -- + + public static Result toSpecification( + PlanResourcesResult planResult, Map mapper) { + return toSpecification(planResult, mapper, Map.of()); + } + + public static Result toSpecification( + PlanResourcesResult planResult, + Map mapper, + Map overrides) { + if (planResult.isAlwaysAllowed()) { + return new Result.AlwaysAllowed<>(); + } + if (planResult.isAlwaysDenied()) { + return new Result.AlwaysDenied<>(); + } + Operand condition = planResult.getCondition() + .orElseThrow(() -> new IllegalArgumentException("Conditional plan has no condition")); + return new Result.Conditional<>((root, query, cb) -> + new Translator(cb, mapper, overrides).traverse(condition, Scope.root(root, query, mapper))); + } + + // -- PlanResourcesResponse overloads -- + + public static Result toSpecification( + PlanResourcesResponse response, Map mapper) { + return toSpecification(response, mapper, Map.of()); + } + + public static Result toSpecification( + PlanResourcesResponse response, + Map mapper, + Map overrides) { + PlanResourcesFilter filter = response.getFilter(); + return switch (filter.getKind()) { + case KIND_ALWAYS_ALLOWED -> new Result.AlwaysAllowed<>(); + case KIND_ALWAYS_DENIED -> new Result.AlwaysDenied<>(); + case KIND_CONDITIONAL -> { + Operand cond = filter.getCondition(); + if (cond.getNodeCase() == Operand.NodeCase.NODE_NOT_SET) { + throw new IllegalArgumentException("Conditional plan has no condition"); + } + yield new Result.Conditional((root, query, cb) -> + new Translator(cb, mapper, overrides).traverse(cond, Scope.root(root, query, mapper))); + } + default -> throw new IllegalArgumentException("Unknown filter kind: " + filter.getKind()); + }; + } + + // -- Internal translator -- + + private static final class Translator { + private final CriteriaBuilder cb; + private final Map topMapper; + private final Map overrides; + + Translator(CriteriaBuilder cb, + Map topMapper, + Map overrides) { + this.cb = cb; + this.topMapper = topMapper; + this.overrides = overrides; + } + + Predicate traverse(Operand operand, Scope scope) { + return switch (operand.getNodeCase()) { + case EXPRESSION -> traverseExpression(operand.getExpression(), scope); + case VARIABLE -> handleBareVariable(operand.getVariable(), scope); + default -> throw new IllegalArgumentException("Unexpected operand type: " + operand.getNodeCase()); + }; + } + + private Predicate handleBareVariable(String variable, Scope scope) { + Path path = scope.resolvePath(variable); + 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 cb.not(traverse(operands.get(0), scope)); + } + case "exists", "exists_one", "all", "except", "filter" -> + handleCollectionOperator(op, operands, scope); + case "hasIntersection" -> handleHasIntersection(operands, scope); + case "isSet" -> handleIsSet(operands, scope); + case "in" -> handleIn(operands, scope); + default -> { + Predicate sizePred = trySizeComparison(op, operands, scope); + if (sizePred != null) { + yield sizePred; + } + yield handleLeafOperator(op, operands, scope); + } + }; + } + + // -- Leaf operators (eq/ne/lt/gt/le/ge/contains/startsWith/endsWith) -- + + private Predicate handleLeafOperator(String op, List operands, Scope scope) { + // Detect leaf comparisons where one side is an 'add' expression (e.g. string + // concatenation: `aString == "prefix:" + R.attr.id`). We fold constants and solve for + // the field side when possible — same algorithm as the Prisma adapter. + Operand addExprOperand = null; + Operand otherOperand = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "add".equals(o.getExpression().getOperator())) { + addExprOperand = o; + } else { + otherOperand = o; + } + } + if (addExprOperand != null) { + if (otherOperand == null) { + throw new IllegalArgumentException("add comparison requires a second operand"); + } + return handleAddComparison(op, addExprOperand.getExpression(), otherOperand, scope); + } + + String variable = null; + Object value = null; + boolean valueSeen = false; + for (Operand o : operands) { + switch (o.getNodeCase()) { + case VARIABLE -> { + if (variable != null) { + // H1: field-to-field comparison is not expressible in JPA Criteria as a + // value-bound predicate. Surface this explicitly rather than the generic + // "Missing value operand" message the loop would otherwise produce. + throw new IllegalArgumentException( + "Field-to-field comparison is not supported for operator '" + + op + "': " + variable + " vs " + o.getVariable()); + } + variable = o.getVariable(); + } + case VALUE -> { + value = protoValueToJava(o.getValue()); + valueSeen = true; + } + case EXPRESSION -> { + // H3: map() compositions are only accepted inside hasIntersection. + // A direct comparison like eq(map(...), [...]) reaches here; point users + // at the supported shape rather than throwing a generic operand error. + String innerOp = o.getExpression().getOperator(); + if ("map".equals(innerOp)) { + throw new IllegalArgumentException( + "Direct comparison of map(...) to a value is not supported " + + "(operator: " + op + "). Wrap the map() expression in " + + "hasIntersection(map(...), [...]) instead."); + } + throw new IllegalArgumentException( + "Unexpected " + innerOp + "() expression in leaf operand of " + op); + } + default -> throw new IllegalArgumentException( + "Unexpected operand type in leaf expression: " + o.getNodeCase()); + } + } + if (variable == null) { + throw new IllegalArgumentException("Missing variable operand for " + op); + } + if (!valueSeen) { + throw new IllegalArgumentException("Missing value operand for " + op); + } + + Path path = scope.resolvePath(variable); + + if (value == null) { + // A registered override owns the operator's full translation, including a null RHS. + OperatorFunction override = overrides.get(op); + if (override != null) { + return override.apply(cb, path, null); + } + return switch (op) { + case "eq" -> cb.isNull(path); + case "ne" -> cb.isNotNull(path); + default -> throw new IllegalArgumentException( + "Null values are only supported with eq and ne operators (got " + op + ")"); + }; + } + + return applyLeaf(op, path, value); + } + + /** + * Apply a scalar leaf operator, consulting the per-operator {@code overrides} hook first so a + * registered {@link OperatorFunction} wins on EVERY path that produces this operator — direct + * comparison, {@code add}-folded comparison, and bare-boolean — not just the direct one. + */ + private Predicate applyLeaf(String op, Path path, Object value) { + OperatorFunction override = overrides.get(op); + if (override != null) { + return override.apply(cb, path, value); + } + return defaultLeaf(op, path, value); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private Predicate defaultLeaf(String op, Path path, Object value) { + Path raw = path; + return switch (op) { + case "eq" -> cb.equal(path, value); + case "ne" -> cb.notEqual(path, value); + case "lt" -> cb.lessThan(raw, (Comparable) value); + case "gt" -> cb.greaterThan(raw, (Comparable) value); + case "le" -> cb.lessThanOrEqualTo(raw, (Comparable) value); + case "ge" -> cb.greaterThanOrEqualTo(raw, (Comparable) value); + case "contains" -> cb.like(path.as(String.class), "%" + escapeLike(String.valueOf(value)) + "%", '\\'); + case "startsWith" -> cb.like(path.as(String.class), escapeLike(String.valueOf(value)) + "%", '\\'); + case "endsWith" -> cb.like(path.as(String.class), "%" + escapeLike(String.valueOf(value)), '\\'); + default -> throw new IllegalArgumentException("Unsupported operator: " + op); + }; + } + + private static String escapeLike(String s) { + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } + + // -- add (fold + solve for string concat / numeric translation) -- + + private Predicate handleAddComparison(String op, PlanResourcesFilter.Expression addExpr, + Operand otherOperand, Scope scope) { + List addOperands = addExpr.getOperandsList(); + if (addOperands.size() != 2) { + throw new IllegalArgumentException("add requires exactly 2 operands"); + } + Operand addLeft = addOperands.get(0); + Operand addRight = addOperands.get(1); + + // Case 1: add(value, value) — fold the two constants, then compare to the field. + if (addLeft.getNodeCase() == Operand.NodeCase.VALUE + && addRight.getNodeCase() == Operand.NodeCase.VALUE) { + Object folded = foldAdd( + protoValueToJava(addLeft.getValue()), + protoValueToJava(addRight.getValue())); + if (otherOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException( + "add(const, const) compared to a non-field operand is not supported"); + } + Path path = scope.resolvePath(otherOperand.getVariable()); + return applyLeaf(op, path, folded); + } + + // Case 2: add(field, value) or add(value, field) — solve for the field. + // Only eq/ne are supported; lt/gt/etc. against a synthesized expression would require + // emitting more complex predicates we don't try to support here. + if (!"eq".equals(op) && !"ne".equals(op)) { + throw new IllegalArgumentException( + "add comparison with a field reference only supports eq/ne (got " + op + ")"); + } + if (otherOperand.getNodeCase() != Operand.NodeCase.VALUE) { + throw new IllegalArgumentException( + "add(field, value) requires a value on the other side of the comparison"); + } + Object otherValue = protoValueToJava(otherOperand.getValue()); + + Operand fieldOp; + Object addConst; + boolean fieldIsLeft; + if (addLeft.getNodeCase() == Operand.NodeCase.VARIABLE + && addRight.getNodeCase() == Operand.NodeCase.VALUE) { + fieldOp = addLeft; + addConst = protoValueToJava(addRight.getValue()); + fieldIsLeft = true; + } else if (addLeft.getNodeCase() == Operand.NodeCase.VALUE + && addRight.getNodeCase() == Operand.NodeCase.VARIABLE) { + fieldOp = addRight; + addConst = protoValueToJava(addLeft.getValue()); + fieldIsLeft = false; + } else { + throw new IllegalArgumentException( + "add requires exactly one field reference and one value, or two values"); + } + + Object solved = solveAdd(otherValue, addConst, fieldIsLeft); + if (solved == null) { + // No solution exists (e.g. "projects:123" == "users:" + R.id can never be true). + // eq → always-false; ne → always-true. + return "eq".equals(op) ? cb.disjunction() : cb.conjunction(); + } + Path path = scope.resolvePath(fieldOp.getVariable()); + return applyLeaf(op, path, solved); + } + + // -- isSet -- + + private Predicate handleIsSet(List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException("isSet requires exactly 2 operands"); + } + String variable = null; + Boolean flag = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.VARIABLE) variable = o.getVariable(); + else if (o.getNodeCase() == Operand.NodeCase.VALUE) { + Object v = protoValueToJava(o.getValue()); + if (!(v instanceof Boolean b)) { + throw new IllegalArgumentException("isSet second operand must be a boolean"); + } + flag = b; + } + } + if (variable == null || flag == null) { + throw new IllegalArgumentException("Invalid isSet operands"); + } + Path path = scope.resolvePath(variable); + OperatorFunction override = overrides.get("isSet"); + if (override != null) { + return override.apply(cb, path, flag); + } + return flag ? cb.isNotNull(path) : cb.isNull(path); + } + + // -- in (set membership or collection membership) -- + + private Predicate handleIn(List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException("in requires exactly 2 operands"); + } + Operand left = operands.get(0); + Operand right = operands.get(1); + + if (left.getNodeCase() == Operand.NodeCase.VARIABLE + && right.getNodeCase() == Operand.NodeCase.VALUE) { + String var = left.getVariable(); + Object val = protoValueToJava(right.getValue()); + + AttributeMapping mapping = scope.resolveMapping(var); + if (mapping instanceof AttributeMapping.Relation rel) { + List values = (val instanceof List l) ? l : List.of(val); + return collectionContainsAny(scope, rel, values); + } + + Path path = scope.resolvePath(var); + OperatorFunction override = overrides.get("in"); + if (override != null) { + return override.apply(cb, path, val); + } + if (val instanceof List list) { + if (list.isEmpty()) { + return cb.disjunction(); + } + return path.in(list); + } + return cb.equal(path, val); + } + + if (left.getNodeCase() == Operand.NodeCase.VALUE + && right.getNodeCase() == Operand.NodeCase.VARIABLE) { + Object val = protoValueToJava(left.getValue()); + String var = right.getVariable(); + + AttributeMapping mapping = scope.resolveMapping(var); + if (mapping instanceof AttributeMapping.Relation rel) { + return collectionContainsAny(scope, rel, List.of(val)); + } + Path path = scope.resolvePath(var); + OperatorFunction override = overrides.get("in"); + if (override != null) { + return override.apply(cb, path, val); + } + return cb.equal(path, val); + } + + throw new IllegalArgumentException( + "Unsupported in operand combination: " + left.getNodeCase() + "/" + right.getNodeCase()); + } + + // -- hasIntersection -- + + private Predicate handleHasIntersection(List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException("hasIntersection requires exactly 2 operands"); + } + Operand first = operands.get(0); + Operand second = operands.get(1); + + if (first.getNodeCase() == Operand.NodeCase.VARIABLE + && second.getNodeCase() == Operand.NodeCase.VALUE) { + String var = first.getVariable(); + Object val = protoValueToJava(second.getValue()); + List values = (val instanceof List l) ? l : List.of(val); + + AttributeMapping mapping = scope.resolveMapping(var); + if (mapping instanceof AttributeMapping.Relation rel) { + return collectionContainsAny(scope, rel, values); + } + Path path = scope.resolvePath(var); + // 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 = protoValueToJava(second.getValue()); + List values = (val instanceof List l) ? l : List.of(val); + // hasIntersection(map(...), []) is always false; short-circuit before the subquery. + if (values.isEmpty()) { + return cb.disjunction(); + } + + PlanResourcesFilter.Expression mapExpr = first.getExpression(); + List mapOperands = mapExpr.getOperandsList(); + if (mapOperands.size() != 2) { + throw new IllegalArgumentException("map requires exactly 2 operands"); + } + Operand collectionOperand = mapOperands.get(0); + Operand lambdaOperand = mapOperands.get(1); + + if (collectionOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("map first operand must be a variable"); + } + if (lambdaOperand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"lambda".equals(lambdaOperand.getExpression().getOperator())) { + throw new IllegalArgumentException("map second operand must be a lambda"); + } + + String collectionVar = collectionOperand.getVariable(); + + PlanResourcesFilter.Expression lambdaExpr = lambdaOperand.getExpression(); + List lambdaOps = lambdaExpr.getOperandsList(); + Operand projection = lambdaOps.get(0); + Operand lambdaVar = lambdaOps.get(1); + if (projection.getNodeCase() != Operand.NodeCase.VARIABLE + || lambdaVar.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("map lambda body must be a simple variable projection"); + } + String memberField = extractLambdaSuffix(projection.getVariable(), lambdaVar.getVariable()); + + // Check whether the collection path resolves through one Relation or a chain. + // A chain (e.g. "request.resource.attr.categories.subCategories") emits nested + // EXISTS subqueries — one per hop. + if (scope instanceof Scope.RootScope rootScope) { + RelationChain chain = resolveRelationChain(rootScope.mapper(), collectionVar); + if (chain != null && !chain.relations().isEmpty()) { + AttributeMapping.Relation tailRel = chain.relations().get(chain.relations().size() - 1); + return chainedExistsSubquery(scope, chain.relations(), (sub, joinFrom) -> { + Path field = resolveMemberPath(joinFrom, tailRel, memberField); + return field.in(values); + }); + } + } + + AttributeMapping mapping = scope.resolveMapping(collectionVar); + if (mapping instanceof AttributeMapping.Relation rel) { + return existsSubquery(scope, rel, (sub, joinFrom) -> { + Path field = resolveMemberPath(joinFrom, rel, memberField); + return field.in(values); + }); + } + throw new IllegalArgumentException( + "map can only be applied to a collection mapped as Relation: " + collectionVar); + } + + throw new IllegalArgumentException( + "Unsupported hasIntersection operand shape: " + first.getNodeCase()); + } + + private Predicate collectionContainsAny(Scope outerScope, AttributeMapping.Relation rel, List values) { + // Intersection with an empty value set is always false — and an EXISTS wrapping an + // empty `IN ()` is dialect-dependent — so short-circuit before building the subquery. + if (values.isEmpty()) { + return cb.disjunction(); + } + return existsSubquery(outerScope, rel, (sub, joinFrom) -> { + Path field; + if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { + field = joinFrom.get(rel.defaultMemberField()); + } else { + // @ElementCollection - the join itself is the element value + field = (Path) joinFrom; + } + if (values.size() == 1) { + return cb.equal(field, values.get(0)); + } + return field.in(values); + }); + } + + // -- size(collection) N -- + + private Predicate trySizeComparison(String op, List operands, Scope scope) { + PlanResourcesFilter.Expression sizeExpr = null; + Long numValue = null; + for (Operand o : operands) { + if (o.getNodeCase() == Operand.NodeCase.EXPRESSION + && "size".equals(o.getExpression().getOperator())) { + sizeExpr = o.getExpression(); + } else if (o.getNodeCase() == Operand.NodeCase.VALUE) { + Object v = protoValueToJava(o.getValue()); + if (v instanceof Number n) numValue = n.longValue(); + } + } + if (sizeExpr == null || numValue == null) { + return null; + } + List sizeOps = sizeExpr.getOperandsList(); + if (sizeOps.size() != 1 || sizeOps.get(0).getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("Unsupported size() expression"); + } + String var = sizeOps.get(0).getVariable(); + AttributeMapping mapping = scope.resolveMapping(var); + if (!(mapping instanceof AttributeMapping.Relation rel)) { + throw new IllegalArgumentException("size() requires a collection (Relation) mapping for " + var); + } + + boolean nonEmpty = ("gt".equals(op) && numValue == 0L) || ("ge".equals(op) && numValue == 1L); + boolean empty = ("eq".equals(op) && numValue == 0L) + || ("le".equals(op) && numValue == 0L) + || ("lt".equals(op) && numValue == 1L); + + if (nonEmpty) { + return existsSubquery(scope, rel, (sub, joinFrom) -> cb.conjunction()); + } + if (empty) { + return cb.not(existsSubquery(scope, rel, (sub, joinFrom) -> cb.conjunction())); + } + throw new IllegalArgumentException( + "Unsupported size comparison: size(" + var + ") " + op + " " + numValue + + ". Only emptiness checks (size > 0, size == 0) are supported."); + } + + // -- exists / exists_one / all / except / filter -- + + @SuppressWarnings("unchecked") + private Predicate handleCollectionOperator(String op, List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException(op + " requires exactly 2 operands"); + } + Operand listOperand = operands.get(0); + Operand lambdaOperand = operands.get(1); + + if (listOperand.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException(op + " first operand must be a variable"); + } + if (lambdaOperand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"lambda".equals(lambdaOperand.getExpression().getOperator())) { + throw new IllegalArgumentException(op + " second operand must be a lambda"); + } + + String collectionVar = listOperand.getVariable(); + AttributeMapping mapping = scope.resolveMapping(collectionVar); + if (!(mapping instanceof AttributeMapping.Relation rel)) { + throw new IllegalArgumentException( + op + " requires a Relation mapping for " + collectionVar); + } + + PlanResourcesFilter.Expression lambdaExpr = lambdaOperand.getExpression(); + List lambdaOps = lambdaExpr.getOperandsList(); + if (lambdaOps.size() != 2) { + throw new IllegalArgumentException("lambda requires exactly 2 operands"); + } + Operand body = lambdaOps.get(0); + Operand lambdaVar = lambdaOps.get(1); + if (lambdaVar.getNodeCase() != Operand.NodeCase.VARIABLE) { + throw new IllegalArgumentException("lambda variable must be a variable operand"); + } + String lambdaVarName = lambdaVar.getVariable(); + + return switch (op) { + case "exists", "filter" -> existsSubquery(scope, rel, + (sub, joinFrom) -> traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))); + case "except" -> existsSubquery(scope, rel, + (sub, joinFrom) -> cb.not(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName)))); + case "all" -> cb.not(existsSubquery(scope, rel, + (sub, joinFrom) -> cb.not(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))))); + case "exists_one" -> { + Subquery sub = scope.parentQuery().subquery(Long.class); + From outerFrom = scope.from(); + From correlated; + if (outerFrom instanceof Root r) { + correlated = sub.correlate(r); + } else if (outerFrom instanceof Join j) { + correlated = sub.correlate((Join) j); + } else { + throw new IllegalArgumentException("Cannot correlate scope: " + outerFrom); + } + Join joinFrom = correlated.join(rel.joinAttribute()); + sub.select(cb.count(joinFrom)); + sub.where(traverse(body, Scope.lambda(joinFrom, sub, rel, lambdaVarName))); + yield cb.equal(sub, 1L); + } + default -> throw new IllegalArgumentException("Unsupported collection operator: " + op); + }; + } + + @FunctionalInterface + private interface SubqueryBodyBuilder { + Predicate build(Subquery sub, From joinFrom); + } + + /** + * Build nested EXISTS subqueries for a chain of Relations: the outermost EXISTS joins the + * first Relation, an inner EXISTS correlates from that join through the next, and so on. + * The {@code bodyBuilder} produces the leaf predicate against the innermost join. + */ + private Predicate chainedExistsSubquery(Scope scope, + java.util.List chain, + SubqueryBodyBuilder bodyBuilder) { + if (chain.size() == 1) { + return existsSubquery(scope, chain.get(0), bodyBuilder); + } + return existsSubquery(scope, chain.get(0), (sub, joinFrom) -> { + // Recurse using an intermediate scope rooted at the current join + this subquery. + // The lambda variable name is internal-only — `$` is not a valid CEL identifier + // character, so this sentinel can never collide with a user-supplied lambda name. + AttributeMapping.Relation thisRel = chain.get(0); + Scope intermediate = Scope.lambda(joinFrom, sub, thisRel, "$$chain$$"); + return chainedExistsSubquery(intermediate, chain.subList(1, chain.size()), bodyBuilder); + }); + } + + @SuppressWarnings("unchecked") + private Predicate existsSubquery(Scope scope, AttributeMapping.Relation rel, SubqueryBodyBuilder bodyBuilder) { + From outerFrom = scope.from(); + Subquery sub = scope.parentQuery().subquery(Integer.class); + From correlated; + if (outerFrom instanceof Root r) { + correlated = sub.correlate(r); + } else if (outerFrom instanceof Join j) { + correlated = sub.correlate((Join) j); + } else { + throw new IllegalArgumentException("Cannot correlate from non-Root, non-Join scope: " + outerFrom); + } + Join joinFrom = correlated.join(rel.joinAttribute()); + sub.select(cb.literal(1)); + Predicate body = bodyBuilder.build(sub, joinFrom); + sub.where(body); + return cb.exists(sub); + } + } + + // -- Scope -- + + private sealed interface Scope permits Scope.RootScope, Scope.LambdaScope { + Path resolvePath(String cerbosVar); + + AttributeMapping resolveMapping(String cerbosVar); + + From from(); + + AbstractQuery parentQuery(); + + static Scope root(From root, AbstractQuery query, Map mapper) { + return new RootScope(root, query, mapper); + } + + static Scope lambda(From from, AbstractQuery parentQuery, + AttributeMapping.Relation relation, String lambdaVar) { + return new LambdaScope(from, parentQuery, relation, lambdaVar); + } + + record RootScope(From from, AbstractQuery parentQuery, Map mapper) + implements Scope { + @Override + public Path resolvePath(String cerbosVar) { + AttributeMapping m = mapper.get(cerbosVar); + if (m == null) { + throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); + } + if (m instanceof AttributeMapping.Field f) { + return traversePath(from, f.jpaPath()); + } + throw new IllegalArgumentException( + "Attribute " + cerbosVar + " is a Relation; cannot resolve as a scalar path"); + } + + @Override + public AttributeMapping resolveMapping(String cerbosVar) { + AttributeMapping m = mapper.get(cerbosVar); + if (m != null) { + return m; + } + + // Try resolving as a dotted suffix off a registered Relation prefix. + // Example: mapper has "request.resource.attr.categories" → Relation("categories", fields={"subCategories": Relation(...)}) + // and we're asked for "request.resource.attr.categories.subCategories" — walk the chain. + String[] parts = cerbosVar.split("\\."); + for (int i = parts.length - 1; i > 0; i--) { + String prefix = String.join(".", java.util.Arrays.copyOfRange(parts, 0, i)); + AttributeMapping prefixMapping = mapper.get(prefix); + if (prefixMapping instanceof AttributeMapping.Relation rel) { + AttributeMapping resolved = walkRelationChain(rel, + java.util.Arrays.copyOfRange(parts, i, parts.length)); + if (resolved != null) { + return resolved; + } + } + } + + throw new IllegalArgumentException("Unknown attribute: " + cerbosVar); + } + } + + record LambdaScope(From from, AbstractQuery parentQuery, + AttributeMapping.Relation relation, String lambdaVar) implements Scope { + @Override + public Path resolvePath(String cerbosVar) { + String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); + if (suffix.isEmpty()) { + if (relation.defaultMemberField() != null && !relation.defaultMemberField().isEmpty()) { + return from.get(relation.defaultMemberField()); + } + return (Path) from; + } + AttributeMapping nested = relation.fields().get(suffix); + if (nested instanceof AttributeMapping.Field f) { + return traversePath(from, f.jpaPath()); + } + return traversePath(from, suffix); + } + + @Override + public AttributeMapping resolveMapping(String cerbosVar) { + String suffix = extractLambdaSuffix(cerbosVar, lambdaVar); + if (suffix.isEmpty()) { + return relation; + } + AttributeMapping nested = relation.fields().get(suffix); + if (nested != null) { + return nested; + } + return AttributeMapping.field(suffix); + } + } + } + + // -- helpers -- + + /** + * Fold {@code add(left, right)} where both operands are constants. Strings concatenate; + * numbers add. Used when the planner emits e.g. {@code eq(field, add("prefix:", "123"))}. + */ + static Object foldAdd(Object left, Object right) { + if (left == null || right == null) { + // Reaching here means the planner emitted `add(null, ...)` or `add(..., null)` + // — neither side could satisfy any string/number equation, so report the shape + // explicitly rather than NPE'ing on `.getClass()` below. + throw new IllegalArgumentException( + "add requires non-null operands, got " + left + " + " + right); + } + if (left instanceof String || right instanceof String) { + return String.valueOf(left) + String.valueOf(right); + } + if (left instanceof Number ln && right instanceof Number rn) { + if (left instanceof Long && right instanceof Long) { + return ln.longValue() + rn.longValue(); + } + return ln.doubleValue() + rn.doubleValue(); + } + throw new IllegalArgumentException( + "add requires string or numeric operands, got " + left.getClass() + " + " + right.getClass()); + } + + /** + * Solve {@code field + addConstant == comparisonValue} (or with operands swapped if + * {@code !fieldIsLeft}). For strings: strip the prefix/suffix and return what the field must + * equal; return {@code null} if the comparison value doesn't match the constant's + * shape (which means no field value can satisfy the equation). For numbers: subtract. + */ + static Object solveAdd(Object comparisonValue, Object addConstant, boolean fieldIsLeft) { + if (comparisonValue instanceof String compStr && addConstant instanceof String constStr) { + if (fieldIsLeft) { + // field + const == comparison → field == comparison stripped-of-suffix + if (!compStr.endsWith(constStr)) return null; + return compStr.substring(0, compStr.length() - constStr.length()); + } + // const + field == comparison → field == comparison stripped-of-prefix + if (!compStr.startsWith(constStr)) return null; + return compStr.substring(constStr.length()); + } + if (comparisonValue instanceof Number compNum && addConstant instanceof Number constNum) { + // Both orderings of numeric addition produce the same equation: field = comp - const + if (comparisonValue instanceof Long && addConstant instanceof Long) { + return compNum.longValue() - constNum.longValue(); + } + return compNum.doubleValue() - constNum.doubleValue(); + } + throw new IllegalArgumentException( + "add comparison type mismatch: " + comparisonValue.getClass() + " vs " + addConstant.getClass()); + } + + /** + * Walk a dotted suffix through a Relation's nested {@code fields()} map. Returns the leaf + * mapping (Field or Relation) reached, or {@code null} if any segment doesn't resolve. + */ + private static AttributeMapping walkRelationChain(AttributeMapping.Relation rel, String[] suffixParts) { + AttributeMapping current = rel; + for (String part : suffixParts) { + if (!(current instanceof AttributeMapping.Relation r)) { + return null; + } + AttributeMapping next = r.fields().get(part); + if (next == null) { + return null; + } + current = next; + } + return current; + } + + /** + * Resolve a dotted top-level Cerbos attribute to a chain of Relations, ending in either a + * leaf Field or the final Relation. Used by {@code hasIntersection(map(...))} when the map's + * collection operand is a dotted path through nested Relation mappings. + */ + record RelationChain(List relations, AttributeMapping.Field tail) {} + + private static RelationChain resolveRelationChain(Map mapper, String cerbosVar) { + AttributeMapping direct = mapper.get(cerbosVar); + if (direct instanceof AttributeMapping.Relation rel) { + return new RelationChain(List.of(rel), null); + } + String[] parts = cerbosVar.split("\\."); + for (int i = parts.length - 1; i > 0; i--) { + String prefix = String.join(".", java.util.Arrays.copyOfRange(parts, 0, i)); + AttributeMapping prefixMapping = mapper.get(prefix); + if (!(prefixMapping instanceof AttributeMapping.Relation rel)) { + continue; + } + String[] suffixParts = java.util.Arrays.copyOfRange(parts, i, parts.length); + java.util.List chain = new java.util.ArrayList<>(); + chain.add(rel); + AttributeMapping current = rel; + boolean ok = true; + for (int s = 0; s < suffixParts.length; s++) { + if (!(current instanceof AttributeMapping.Relation r)) { + ok = false; + break; + } + AttributeMapping next = r.fields().get(suffixParts[s]); + if (next == null) { + ok = false; + break; + } + if (next instanceof AttributeMapping.Relation nextRel) { + chain.add(nextRel); + current = nextRel; + } else if (next instanceof AttributeMapping.Field leafField && s == suffixParts.length - 1) { + return new RelationChain(chain, leafField); + } else { + ok = false; + break; + } + } + if (ok) { + return new RelationChain(chain, null); + } + } + return null; + } + + private static Path resolveMemberPath(From joinFrom, AttributeMapping.Relation rel, String memberField) { + if (memberField == null || memberField.isEmpty()) { + if (rel.defaultMemberField() != null && !rel.defaultMemberField().isEmpty()) { + return joinFrom.get(rel.defaultMemberField()); + } + return (Path) joinFrom; + } + AttributeMapping nested = rel.fields().get(memberField); + if (nested instanceof AttributeMapping.Field f) { + return traversePath(joinFrom, f.jpaPath()); + } + return traversePath(joinFrom, memberField); + } + + private static Path traversePath(From from, String dottedJpaPath) { + String[] parts = dottedJpaPath.split("\\."); + Path p = from; + for (String part : parts) { + p = p.get(part); + } + return p; + } + + private static String extractLambdaSuffix(String variable, String lambdaVar) { + if (variable.equals(lambdaVar)) { + return ""; + } + String prefix = lambdaVar + "."; + if (!variable.startsWith(prefix)) { + throw new IllegalArgumentException( + "Variable '" + variable + "' does not start with lambda variable '" + lambdaVar + "'"); + } + return variable.substring(prefix.length()); + } + + static Object protoValueToJava(Value value) { + return switch (value.getKindCase()) { + case STRING_VALUE -> value.getStringValue(); + case NUMBER_VALUE -> { + double d = value.getNumberValue(); + if (d == Math.floor(d) && !Double.isInfinite(d)) { + yield (long) d; + } + yield d; + } + case BOOL_VALUE -> value.getBoolValue(); + case NULL_VALUE -> null; + case LIST_VALUE -> value.getListValue().getValuesList().stream() + .map(SpringDataQueryPlanAdapter::protoValueToJava) + .toList(); + case STRUCT_VALUE -> value.getStructValue().getFieldsMap().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> protoValueToJava(e.getValue()))); + case KIND_NOT_SET -> throw new IllegalArgumentException( + "Protobuf Value has no kind set — the planner emitted a malformed operand"); + default -> throw new IllegalArgumentException( + "Unsupported protobuf value type: " + value.getKindCase()); + }; + } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java new file mode 100644 index 00000000..26b95b7b --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -0,0 +1,1154 @@ +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")) + ); + + // Combined map used by tests that reference both nested.* and categories (e.g. combined-or) + // or the full kitchen-sink (tags + nested + tagObjects + ...). + private static final Map COMBINED_MAP; + static { + java.util.HashMap m = new java.util.HashMap<>(); + m.put("request.resource.attr.aBool", AttributeMapping.field("aBool")); + m.put("request.resource.attr.aString", AttributeMapping.field("aString")); + m.put("request.resource.attr.aNumber", AttributeMapping.field("aNumber")); + m.put("request.resource.attr.id", AttributeMapping.field("oid")); + m.put("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")); + m.put("request.resource.attr.createdBy", AttributeMapping.field("createdBy")); + m.put("request.resource.attr.ownedBy", AttributeMapping.relation("ownedBy")); + // tags as the @OneToMany TagEntity collection (for exists/all/filter etc.) + m.put("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "id", AttributeMapping.field("id"), + "name", AttributeMapping.field("name") + ))); + m.put("request.resource.attr.nested.aBool", AttributeMapping.field("nested.aBool")); + m.put("request.resource.attr.nested.aString", AttributeMapping.field("nested.aString")); + m.put("request.resource.attr.nested.aNumber", AttributeMapping.field("nested.aNumber")); + m.put("request.resource.attr.nested.aOptionalString", AttributeMapping.field("nested.aOptionalString")); + m.put("request.resource.attr.nested.nextlevel.aBool", AttributeMapping.field("nested.nextlevel.aBool")); + m.put("request.resource.attr.nested.nextlevel.aString", AttributeMapping.field("nested.nextlevel.aString")); + m.put("request.resource.attr.categories", AttributeMapping.relation("categories", Map.of( + "name", AttributeMapping.field("name"), + "subCategories", AttributeMapping.relation("subCategories", Map.of( + "name", AttributeMapping.field("name"), + "labels", AttributeMapping.relation("labels", Map.of( + "name", AttributeMapping.field("name") + )) + )) + ))); + COMBINED_MAP = Map.copyOf(m); + } + + private static final Map CATEGORIES_MAP = Map.ofEntries( + Map.entry("request.resource.attr.categories", AttributeMapping.relation("categories", Map.of( + "name", AttributeMapping.field("name"), + "subCategories", AttributeMapping.relation("subCategories", Map.of( + "name", AttributeMapping.field("name"), + "labels", AttributeMapping.relation("labels", Map.of( + "name", AttributeMapping.field("name") + )) + )) + ))) + ); + + private static final Map NESTED_FIELD_MAP = Map.ofEntries( + Map.entry("request.resource.attr.aBool", AttributeMapping.field("aBool")), + Map.entry("request.resource.attr.aString", AttributeMapping.field("aString")), + Map.entry("request.resource.attr.aNumber", AttributeMapping.field("aNumber")), + Map.entry("request.resource.attr.id", AttributeMapping.field("oid")), + Map.entry("request.resource.attr.aOptionalString", AttributeMapping.field("aOptionalString")), + Map.entry("request.resource.attr.createdBy", AttributeMapping.field("createdBy")), + Map.entry("request.resource.attr.ownedBy", AttributeMapping.relation("ownedBy")), + Map.entry("request.resource.attr.tags", AttributeMapping.relation("tags", Map.of( + "id", AttributeMapping.field("id"), + "name", AttributeMapping.field("name") + ))), + Map.entry("request.resource.attr.nested.aBool", AttributeMapping.field("nested.aBool")), + Map.entry("request.resource.attr.nested.aString", AttributeMapping.field("nested.aString")), + Map.entry("request.resource.attr.nested.aNumber", AttributeMapping.field("nested.aNumber")) + ); + + /** Records every SQL statement Hibernate executes, so a test can assert on query shape. */ + public static final class SqlCapture implements org.hibernate.resource.jdbc.spi.StatementInspector { + static final java.util.List STATEMENTS = + java.util.Collections.synchronizedList(new java.util.ArrayList<>()); + + @Override + public String inspect(String sql) { + STATEMENTS.add(sql); + return sql; + } + } + + private static GenericContainer createCerbosContainer() { + GenericContainer container = new GenericContainer<>("ghcr.io/cerbos/cerbos:latest") + .withExposedPorts(3593) + .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.setOwnedBy(new java.util.ArrayList<>(List.of("user1", "user2"))); + r1.setTagNames(new java.util.ArrayList<>(List.of("public", "featured"))); + r1.addTag("tag1", "public"); + r1.addTag("tag2", "private"); + r1.setCategories(new java.util.ArrayList<>(List.of(cat1))); + r1.setCreator(user1); + NestedEmbeddable n1 = new NestedEmbeddable(); + n1.setaBool(true); + n1.setaString("substring"); + n1.setaNumber(2); + NextLevelEmbeddable nl1 = new NextLevelEmbeddable(); + nl1.setaBool(true); + nl1.setaString("strDeep"); + n1.setNextlevel(nl1); + r1.setNested(n1); + em.persist(r1); + + ResourceEntity r2 = new ResourceEntity("2"); + r2.setOid("507f1f77bcf86cd799439012"); + r2.setaBool(false); + r2.setaString("amIAString?"); + r2.setaNumber(2); + r2.setCreatedBy("user2"); + r2.setOwnedBy(new java.util.ArrayList<>(List.of("user2"))); + r2.setTagNames(new java.util.ArrayList<>(List.of("private"))); + r2.addTag("tag3", "private"); + r2.setCategories(new java.util.ArrayList<>(List.of(cat2))); + r2.setCreator(user2); + NestedEmbeddable n2 = new NestedEmbeddable(); + n2.setaBool(false); + n2.setaString("noMatch"); + n2.setaNumber(1); + NextLevelEmbeddable nl2 = new NextLevelEmbeddable(); + nl2.setaBool(false); + nl2.setaString("deepValue"); + n2.setNextlevel(nl2); + r2.setNested(n2); + em.persist(r2); + + ResourceEntity r3 = new ResourceEntity("3"); + r3.setOid("507f1f77bcf86cd799439013"); + r3.setaBool(true); + r3.setaString("anotherString"); + r3.setaNumber(3); + r3.setaOptionalString("world"); + r3.setCreatedBy("user3"); + r3.setOwnedBy(new java.util.ArrayList<>(List.of("user1"))); + r3.setTagNames(new java.util.ArrayList<>(List.of("public"))); + r3.addTag("tag1", "public"); + r3.setCategories(new java.util.ArrayList<>(List.of(cat1, cat2))); + r3.setCreator(user3); + NestedEmbeddable n3 = new NestedEmbeddable(); + n3.setaBool(true); + n3.setaString("testString"); + n3.setaNumber(3); + NextLevelEmbeddable nl3 = new NextLevelEmbeddable(); + nl3.setaBool(false); + nl3.setaString("strValue"); + n3.setNextlevel(nl3); + r3.setNested(n3); + em.persist(r3); + + tx.commit(); + em.close(); + } + + private static PlanResourcesResult plan(String action) { + return plan(Principal.newInstance("user1", "USER"), action); + } + + private static PlanResourcesResult plan(Principal principal, String action) { + return cerbosClient.plan( + principal, + Resource.newInstance("resource"), + action); + } + + private static List runWithMapping(String action, Map mapping) { + return runWithPrincipalAndMapping(Principal.newInstance("user1", "USER"), action, mapping); + } + + private static List runWithPrincipalAndMapping( + Principal principal, String action, Map mapping) { + PlanResourcesResult planResult = plan(principal, action); + Result result = + SpringDataQueryPlanAdapter.toSpecification(planResult, mapping); + + if (result instanceof Result.AlwaysDenied) { + return List.of(); + } + + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root root = cq.from(ResourceEntity.class); + cq.select(root.get("id")).distinct(true); + + if (result instanceof Result.Conditional conditional) { + Specification spec = conditional.specification(); + Predicate p = spec.toPredicate(root, cq, cb); + if (p != null) { + cq.where(p); + } + } + cq.orderBy(cb.asc(root.get("id"))); + return em.createQuery(cq).getResultList(); + } finally { + em.close(); + } + } + + private static List run(String action) { + return runWithMapping(action, FIELD_MAP); + } + + private static List runNested(String action) { + return runWithMapping(action, NESTED_FIELD_MAP); + } + + /** + * 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"); + // One correlated EXISTS per relation hop (categories -> subCategories -> labels). + assertEquals(3, countOccurrences(sql, "exists"), + "expected three nested EXISTS subqueries, SQL was:\n" + sql); + // Correlated subqueries must not collapse into a cartesian product. + assertFalse(sql.contains("cross join"), + "nested correlation degraded into a cross join, SQL was:\n" + sql); + } + + private int countOccurrences(String haystack, String needle) { + int count = 0; + for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + needle.length())) { + count++; + } + return count; + } + } + + // -- Single-valued (@ManyToOne) relation: resource.creator.{name,department} via dotted Field -- + // No special Relation declaration needed — Field("creator.name") traverses the JPA path naturally. + @Nested + class SingleValuedRelations { + + @Test + void manyToOneTraversal() { + // Synthetic: bare-bool action on aBool — we just confirm the dotted-path Field works + // by reusing an existing simple test. The is-set test below covers the real value. + Map mapping = new java.util.HashMap<>(FIELD_MAP); + mapping.put("request.resource.attr.createdBy", AttributeMapping.field("creator.id")); + + // relation-is policy: createdBy == P.id ("user1") → r1 + // With our remapping, createdBy now resolves through the @ManyToOne creator → id column. + assertEquals(List.of("1"), runWithMapping("relation-is", mapping)); + } + + @Test + void isSetNested() { + // request.resource.attr.nested.aOptionalString != null + // Only r1's nested has aOptionalString set... actually we didn't set it, so all are null. + // To actually test this, set on r1's nested. We do it via a separate test setup. + // Here we just verify the predicate compiles and runs without error. + assertEquals(List.of(), runWithMapping("is-set-nested", FIELD_MAP)); + } + } + + // -- Combined: mixing nested + categories in a single OR expression -- + @Nested + class CombinedExpressions { + + @Test + void combinedOr() { + // nested.nextlevel.aBool == true OR categories.exists(cat, cat.name == "business") + // r1: nextlevel.aBool=true → matches + // r2: nextlevel.aBool=false, categories=[development] → no match + // r3: nextlevel.aBool=false, categories=[business, development] → matches via business + assertEquals(List.of("1", "3"), runWithMapping("combined-or", COMBINED_MAP)); + } + } + + // -- Add operator: string concatenation with constant folding/solving -- + @Nested + class AddOperator { + + @Test + void stringConcatPrincipal() { + // Policy: + // any: + // - P.attr.context == "projects" + // - P.attr.context == "projects:" + R.attr.id + // + // With principal.context = "projects:507f1f77bcf86cd799439011": + // 1st branch is false at plan time → dropped + // 2nd branch becomes: "projects:507f1f77bcf86cd799439011" == "projects:" + R.attr.id + // → adapter solves to: R.attr.id == "507f1f77bcf86cd799439011" → matches r1 + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("context", + AttributeValue.stringValue("projects:507f1f77bcf86cd799439011")); + + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "string-concat-principal", COMBINED_MAP)); + } + + @Test + void stringConcatPrincipalNoMatch() { + // P.attr.context = "projects:does-not-exist" → solves to R.attr.id == "does-not-exist" + // → no resource matches + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("context", AttributeValue.stringValue("projects:does-not-exist")); + + assertEquals(List.of(), runWithPrincipalAndMapping( + principal, "string-concat-principal", COMBINED_MAP)); + } + + @Test + void stringConcatPrincipalShortCircuit() { + // P.attr.context = "projects" → first branch is TRUE at plan time → always-allowed + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("context", AttributeValue.stringValue("projects")); + + assertEquals(List.of("1", "2", "3"), runWithPrincipalAndMapping( + principal, "string-concat-principal", COMBINED_MAP)); + } + } + + // -- Principal attributes: actions that read request.principal.attr.* -- + @Nested + class PrincipalAttributes { + + @Test + void hasIntersectionWithPrincipalTags() { + // hasIntersection(R.attr.tags.map(t, t.name), P.attr.tags) + // P.attr.tags = ["public", "private"] → planner substitutes: + // hasIntersection(map(R.attr.tags, t, t.name), ["public", "private"]) + // Adapter emits a correlated EXISTS over tags where tags.name IN ["public","private"]: + // r1 has [public, private] → ✓ + // r2 has [private] → ✓ + // r3 has [public] → ✓ + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("tags", AttributeValue.listValue( + AttributeValue.stringValue("public"), + AttributeValue.stringValue("private"))); + + assertEquals(List.of("1", "2", "3"), runWithPrincipalAndMapping( + principal, "has-intersection", COMBINED_MAP)); + } + + @Test + void hasIntersectionPrincipalTagsNoMatch() { + // P.attr.tags = ["nonexistent"] → no resource has a tag with that name → [] + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("tags", + AttributeValue.listValue(AttributeValue.stringValue("nonexistent"))); + + assertEquals(List.of(), runWithPrincipalAndMapping( + principal, "has-intersection", COMBINED_MAP)); + } + + @Test + void kitchensink() { + // The kitchensink action AND-combines: + // 1. R.attr.tags.filter(tag, tag.name == "public") (treated as exists) + // 2. any-of {aOptionalString!=null, aBool==true, exists(tag.id=="tag1" && tag.name=="public"), + // nested.aNumber>1, endsWith("ing"), startsWith("ing"), contains("ing")} + // 3. all-of {hasIntersection(tags.map(t, t.name), P.attr.tags), + // "public" in P.attr.tags, (folded at plan time) + // nested.nextlevel.aBool == true} + // + // With P.attr.tags = ["public"]: + // 3's "public" in P.attr.tags → TRUE at plan time → dropped + // 3 simplifies to: hasIntersection(tags.map, ["public"]) AND nested.nextlevel.aBool==true + // + // Per resource: + // r1: filter(public)=hit ✓; any: aOptional!=null ✓; hasIntersection ✓ (tag name "public"); nextlevel.aBool=true ✓ → MATCH + // r2: filter(public) → no public tag → ✗ + // r3: filter(public)=hit ✓; any: aOptional!=null ✓; hasIntersection ✓; nextlevel.aBool=false → ✗ on cond 3 + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("tags", + AttributeValue.listValue(AttributeValue.stringValue("public"))); + + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "kitchensink", COMBINED_MAP)); + } + } + + // -- DeMorgan / negated operator wrappers (PR #222) -- + // The adapter handles `not` by wrapping `cb.not(...)` around the inner predicate; + // every supported inner operator composes without source changes. + + @Nested + class DeMorganNegation { + + @Test + void notAnd() { + // !(aBool == true && aString != "string") + // r1: !(true && false) → true ✓ + // r2: !(false && _) → true ✓ + // r3: !(true && true) → false ✗ + assertEquals(List.of("1", "2"), run("not-and")); + } + + @Test + void notOr() { + // !(aBool == true || aString != "string") + // r1: !(true || false) → false ✗ + // r2: !(false || true) → false ✗ + // r3: !(true || true) → false ✗ + assertEquals(List.of(), run("not-or")); + } + + @Test + void notGt() { + // !(aNumber > 1) → aNumber <= 1; only r1 (aNumber=1) + assertEquals(List.of("1"), run("not-gt")); + } + + @Test + void notLt() { + // !(aNumber < 2) → aNumber >= 2; r2 (2), r3 (3) + assertEquals(List.of("2", "3"), run("not-lt")); + } + + @Test + void notContains() { + // !aString.contains("str") — H2 LIKE is case-sensitive by default. + // r1: "string" contains "str" → excluded + // r2: "amIAString?" → match (capital 'S') + // r3: "anotherString" → match (capital 'S') + assertEquals(List.of("2", "3"), run("not-contains")); + } + + @Test + void notStartsWith() { + // r1: "string" → excluded + // r2: "amIAString?" → match + // r3: "anotherString" → match + assertEquals(List.of("2", "3"), run("not-starts-with")); + } + } + + // -- CEL primitives (PR #223) -- + // Only `empty-collection` (size(coll) == 0) is natively supported via the existing emptiness + // path in trySizeComparison. Arithmetic, regex, casts, ternary, list indexing, and + // size() over a scalar string all throw — the Spring Data adapter has no shape for them in + // its Criteria-based predicate builder. + + @Nested + class CelPrimitives { + + @Test + void emptyCollection() { + // size(R.attr.tags) == 0 — every resource has non-empty tagNames. + assertEquals(List.of(), run("empty-collection")); + } + + @Test + void arithAddThrows() { + // The planner emits gt(add(field, 1.0), 2.0); handleAddComparison rejects non-eq/ne. + assertActionThrows("arith-add", FIELD_MAP, "add", "gt"); + } + + @Test + void arithSubThrows() { + assertActionThrows("arith-sub", FIELD_MAP, "sub"); + } + + @Test + void arithMultThrows() { + assertActionThrows("arith-mult", FIELD_MAP, "mult"); + } + + @Test + void arithDivThrows() { + assertActionThrows("arith-div", FIELD_MAP, "div"); + } + + @Test + void arithModThrows() { + 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 ternaryThrows() { + // The CEL planner emits ternary as `if(cond, then, else)` — not `conditional`. + assertActionThrows("ternary", FIELD_MAP, "if()"); + } + + @Test + void stringSizeThrows() { + // size(R.attr.aString) > 0 — adapter only handles size() on Relation mappings. + assertActionThrows("string-size", FIELD_MAP, "size()", "Relation"); + } + } + + // -- Minor operator/comparison shapes (PR #234) -- + + @Nested + class MinorOperators { + + @Test + void isNotSet() { + // aOptionalString == null → only r2 (others have "hello"/"world") + assertEquals(List.of("2"), run("is-not-set")); + } + + @Test + void equalFieldToFieldThrows() { + // aString == id — adapter rejects two-variable comparisons with a specific message. + assertActionThrows("equal-field-to-field", FIELD_MAP, "Field-to-field", "eq"); + } + + @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"); + } + + // TODO(#232): trySizeComparison only accepts a Variable as size()'s operand, so + // `size(filter(...)) > 0` falls through and throws. + @Test + void sizeOfFilterThrows() { + assertActionThrows("filter-count-gt", NESTED_FIELD_MAP, "size()"); + } + } +} 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..f4696ca9 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -0,0 +1,860 @@ +package dev.cerbos.queryplan.springdata; + +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; +import com.google.protobuf.Value; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression; +import dev.cerbos.api.v1.engine.Engine.PlanResourcesFilter.Expression.Operand; +import dev.cerbos.api.v1.response.Response.PlanResourcesResponse; +import dev.cerbos.queryplan.springdata.testmodel.ResourceEntity; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.domain.Specification; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.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) { + PlanResourcesResponse resp = + buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, condition); + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER); + assertInstanceOf(Result.Conditional.class, result); + Specification spec = ((Result.Conditional) result).specification(); + + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(ResourceEntity.class); + cq.select(cb.count(root)); + Predicate p = spec.toPredicate(root, cq, cb); + if (p != null) cq.where(p); + return em.createQuery(cq).getSingleResult().intValue(); + } finally { + em.close(); + } + } + + /** {@link #runCount(Operand)} with per-operator overrides. */ + private static int runCount(Operand condition, Map overrides) { + PlanResourcesResponse resp = + buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, condition); + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER, overrides); + assertInstanceOf(Result.Conditional.class, result); + Specification spec = ((Result.Conditional) result).specification(); + + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(ResourceEntity.class); + cq.select(cb.count(root)); + Predicate p = spec.toPredicate(root, cq, cb); + if (p != null) cq.where(p); + return em.createQuery(cq).getSingleResult().intValue(); + } finally { + em.close(); + } + } + + /** Thrown by {@link #THROWING_OVERRIDE} to prove an override hook was actually invoked. */ + private static final class OverrideInvoked extends RuntimeException { + OverrideInvoked() { + super("override invoked"); + } + } + + /** An override that fails loudly when reached, so a test can assert the override path is taken. */ + private static final OperatorFunction THROWING_OVERRIDE = (cb, field, value) -> { + throw new OverrideInvoked(); + }; + + @Test + void alwaysAllowedResult() { + PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_ALWAYS_ALLOWED, null); + 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)))); + } + + @Test + void unsupportedSizeComparisonThrows() { + // Only emptiness checks (size > 0, size == 0) are supported; size > 5 must throw. + assertConditionThrows( + exprOp("gt", + exprOp("size", var("request.resource.attr.ownedBy")), + nval(5)), + "size", "Unsupported size comparison"); + } + + @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)); + } + + // -- DeMorgan / negated operator wrappers (PR #222) -- + + @Nested + class DeMorganNegation { + + @Test + void notAnd() { + // !(aBool == true && aString != "string") + assertEquals(0, runCount(exprOp("not", + exprOp("and", + exprOp("eq", var("request.resource.attr.aBool"), bval(true)), + exprOp("ne", var("request.resource.attr.aString"), sval("string")))))); + } + + @Test + void notOr() { + assertEquals(0, runCount(exprOp("not", + exprOp("or", + exprOp("eq", var("request.resource.attr.aBool"), bval(true)), + exprOp("ne", var("request.resource.attr.aString"), sval("string")))))); + } + + @Test + void notGt() { + assertEquals(0, runCount(exprOp("not", + exprOp("gt", var("request.resource.attr.aNumber"), nval(1))))); + } + + @Test + void notLt() { + assertEquals(0, runCount(exprOp("not", + exprOp("lt", var("request.resource.attr.aNumber"), nval(2))))); + } + + @Test + void notContains() { + assertEquals(0, runCount(exprOp("not", + exprOp("contains", var("request.resource.attr.aString"), sval("str"))))); + } + + @Test + void notStartsWith() { + assertEquals(0, runCount(exprOp("not", + exprOp("startsWith", var("request.resource.attr.aString"), sval("str"))))); + } + } + + // -- CEL primitives (PR #223): only empty-collection is natively supported; the rest throw -- + + @Nested + class CelPrimitives { + + @Test + void emptyCollectionBuildsNotExists() { + // size(R.attr.tags) == 0 — tags mapped as Relation → not-exists subquery. + assertEquals(0, runCount(exprOp("eq", + exprOp("size", var("request.resource.attr.tags")), + nval(0)))); + } + + @Test + void arithAddInComparisonThrows() { + // gt(add(field, 1.0), 2.0) — handleAddComparison rejects non-eq/ne ops. + assertConditionThrows( + exprOp("gt", + exprOp("add", var("request.resource.attr.aNumber"), nval(1)), + nval(2)), + "add", "gt"); + } + + @Test + void arithSubThrows() { + assertConditionThrows( + exprOp("lt", + exprOp("sub", var("request.resource.attr.aNumber"), nval(1)), + nval(2)), + "sub"); + } + + @Test + void arithMultThrows() { + assertConditionThrows( + exprOp("gt", + exprOp("mult", var("request.resource.attr.aNumber"), nval(2)), + nval(2)), + "mult"); + } + + @Test + void arithDivThrows() { + assertConditionThrows( + exprOp("gt", + exprOp("div", var("request.resource.attr.aNumber"), nval(2)), + nval(0)), + "div"); + } + + @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 ternaryThrows() { + // The CEL planner emits ternary as `if(cond, then, else)` in the AST. + Operand ternary = exprOp("if", + var("request.resource.attr.aBool"), + var("request.resource.attr.aNumber"), + nval(0)); + assertConditionThrows(exprOp("gt", ternary, nval(0)), "if()"); + } + + @Test + void stringSizeThrows() { + // size(aString) > 0 — size() requires a Relation mapping; aString is a Field. + assertConditionThrows( + exprOp("gt", + exprOp("size", var("request.resource.attr.aString")), + nval(0)), + "size()", "Relation"); + } + } + + // -- Minor operator/comparison shapes (PR #234) -- + + @Nested + class MinorOperators { + + @Test + void isNotSetBuildsIsNull() { + // aOptionalString == null — adapter routes eq(field, null) to cb.isNull. + assertEquals(0, runCount(exprOp("eq", + var("request.resource.attr.aOptionalString"), nullVal()))); + } + + @Test + void equalFieldToFieldThrows() { + // eq(var, var) — adapter rejects two-variable comparisons with a specific message. + assertConditionThrows( + exprOp("eq", + var("request.resource.attr.aString"), + var("request.resource.attr.createdBy")), + "Field-to-field", "eq"); + } + + @Test + 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 sizeOfFilterThrows() { + // size(tags.filter(t, t.name == "public")) > 0 — size() operand must be a variable. + Operand filterExpr = exprOp("filter", + var("request.resource.attr.tags"), + lambda("t", exprOp("eq", var("t.name"), sval("public")))); + assertConditionThrows( + exprOp("gt", exprOp("size", filterExpr), nval(0)), + "size()"); + } + } + + @Test + void operatorOverrideIsUsed() { + Operand cond = exprOp("eq", var("request.resource.attr.aString"), sval("foo")); + PlanResourcesResponse resp = buildResponse(PlanResourcesFilter.Kind.KIND_CONDITIONAL, cond); + + // Override eq to always produce IS NULL — so result count is 0 (no nulls in empty table either, still 0). + Map overrides = Map.of( + "eq", (cb, field, value) -> cb.isNull(field)); + + Result result = + SpringDataQueryPlanAdapter.toSpecification(resp, MAPPER, overrides); + assertInstanceOf(Result.Conditional.class, result); + + Specification spec = ((Result.Conditional) result).specification(); + EntityManager em = emf.createEntityManager(); + try { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(ResourceEntity.class); + cq.select(cb.count(root)); + Predicate p = spec.toPredicate(root, cq, cb); + cq.where(p); + assertEquals(0L, em.createQuery(cq).getSingleResult().longValue()); + } finally { + em.close(); + } + } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/CategoryEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/CategoryEntity.java new file mode 100644 index 00000000..7abc0327 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/CategoryEntity.java @@ -0,0 +1,47 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "categories") +public class CategoryEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @ManyToMany(mappedBy = "categories") + private List resources = new ArrayList<>(); + + @ManyToMany + @JoinTable(name = "category_subcategory", + joinColumns = @JoinColumn(name = "category_id"), + inverseJoinColumns = @JoinColumn(name = "subcategory_id")) + private List subCategories = new ArrayList<>(); + + public CategoryEntity() {} + + public CategoryEntity(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public List getResources() { return resources; } + public List getSubCategories() { return subCategories; } + public void setSubCategories(List subCategories) { this.subCategories = subCategories; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/LabelEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/LabelEntity.java new file mode 100644 index 00000000..7a4a2295 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/LabelEntity.java @@ -0,0 +1,37 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "labels") +public class LabelEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @ManyToMany(mappedBy = "labels") + private List subCategories = new ArrayList<>(); + + public LabelEntity() {} + + public LabelEntity(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public List getSubCategories() { return subCategories; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NestedEmbeddable.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NestedEmbeddable.java new file mode 100644 index 00000000..94e275bc --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NestedEmbeddable.java @@ -0,0 +1,37 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Embedded; + +@Embeddable +public class NestedEmbeddable { + + @Column(name = "nested_a_bool") + private Boolean aBool; + + @Column(name = "nested_a_string") + private String aString; + + @Column(name = "nested_a_number") + private Integer aNumber; + + @Column(name = "nested_optional_string") + private String aOptionalString; + + @Embedded + private NextLevelEmbeddable nextlevel; + + public NestedEmbeddable() {} + + public Boolean getaBool() { return aBool; } + public void setaBool(Boolean aBool) { this.aBool = aBool; } + public String getaString() { return aString; } + public void setaString(String aString) { this.aString = aString; } + public Integer getaNumber() { return aNumber; } + public void setaNumber(Integer aNumber) { this.aNumber = aNumber; } + public String getaOptionalString() { return aOptionalString; } + public void setaOptionalString(String aOptionalString) { this.aOptionalString = aOptionalString; } + public NextLevelEmbeddable getNextlevel() { return nextlevel; } + public void setNextlevel(NextLevelEmbeddable nextlevel) { this.nextlevel = nextlevel; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NextLevelEmbeddable.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NextLevelEmbeddable.java new file mode 100644 index 00000000..df175a0e --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/NextLevelEmbeddable.java @@ -0,0 +1,21 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +@Embeddable +public class NextLevelEmbeddable { + + @Column(name = "next_a_bool") + private Boolean aBool; + + @Column(name = "next_a_string") + private String aString; + + public NextLevelEmbeddable() {} + + public Boolean getaBool() { return aBool; } + public void setaBool(Boolean aBool) { this.aBool = aBool; } + public String getaString() { return aString; } + public void setaString(String aString) { this.aString = aString; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/OwnerEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/OwnerEntity.java new file mode 100644 index 00000000..29240d8c --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/OwnerEntity.java @@ -0,0 +1,39 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * A simple owner/user entity used to demonstrate single-valued (@ManyToOne) relation traversal. + * Cerbos plans that reference {@code request.resource.attr.creator.name} or similar dotted paths + * are translated to {@code root.get("creator").get("name")} JPA paths — no special configuration + * is required for one-to-one or many-to-one relations beyond the dotted field mapping. + */ +@Entity +@Table(name = "owners") +public class OwnerEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @Column(name = "department") + private String department; + + public OwnerEntity() {} + + public OwnerEntity(String id, String name, String department) { + this.id = id; + this.name = name; + this.department = department; + } + + public String getId() { return id; } + public String getName() { return name; } + public String getDepartment() { return department; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java new file mode 100644 index 00000000..d1c029b1 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java @@ -0,0 +1,110 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "resources") +public class ResourceEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "oid") + private String oid; + + @Column(name = "a_bool") + private Boolean aBool; + + @Column(name = "a_string") + private String aString; + + @Column(name = "a_number") + private Integer aNumber; + + @Column(name = "a_optional_string") + private String aOptionalString; + + @Column(name = "created_by") + private String createdBy; + + @ElementCollection + @CollectionTable(name = "resource_owned_by", joinColumns = @JoinColumn(name = "resource_id")) + @Column(name = "owner") + private List ownedBy = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "resource_tag_names", joinColumns = @JoinColumn(name = "resource_id")) + @Column(name = "tag_name") + private List tagNames = new ArrayList<>(); + + @OneToMany(mappedBy = "resource", cascade = CascadeType.ALL, orphanRemoval = true) + private List tags = new ArrayList<>(); + + @ManyToMany + @JoinTable(name = "resource_category", + joinColumns = @JoinColumn(name = "resource_id"), + inverseJoinColumns = @JoinColumn(name = "category_id")) + private List categories = new ArrayList<>(); + + @ManyToOne + @JoinColumn(name = "creator_id") + private OwnerEntity creator; + + @Embedded + private NestedEmbeddable nested; + + public ResourceEntity() {} + + public ResourceEntity(String id) { + this.id = id; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getOid() { return oid; } + public void setOid(String oid) { this.oid = oid; } + public Boolean getaBool() { return aBool; } + public void setaBool(Boolean aBool) { this.aBool = aBool; } + public String getaString() { return aString; } + public void setaString(String aString) { this.aString = aString; } + public Integer getaNumber() { return aNumber; } + public void setaNumber(Integer aNumber) { this.aNumber = aNumber; } + public String getaOptionalString() { return aOptionalString; } + public void setaOptionalString(String aOptionalString) { this.aOptionalString = aOptionalString; } + public String getCreatedBy() { return createdBy; } + public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } + public List getOwnedBy() { return ownedBy; } + public void setOwnedBy(List ownedBy) { this.ownedBy = ownedBy; } + public List getTagNames() { return tagNames; } + public void setTagNames(List tagNames) { this.tagNames = tagNames; } + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } + public List getCategories() { return categories; } + public void setCategories(List categories) { this.categories = categories; } + public OwnerEntity getCreator() { return creator; } + public void setCreator(OwnerEntity creator) { this.creator = creator; } + public NestedEmbeddable getNested() { return nested; } + public void setNested(NestedEmbeddable nested) { this.nested = nested; } + + public ResourceEntity addTag(String tagId, String tagName) { + TagEntity t = new TagEntity(tagId, tagName, this); + tags.add(t); + return this; + } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/SubCategoryEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/SubCategoryEntity.java new file mode 100644 index 00000000..98b5db98 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/SubCategoryEntity.java @@ -0,0 +1,47 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "subcategories") +public class SubCategoryEntity { + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @ManyToMany(mappedBy = "subCategories") + private List categories = new ArrayList<>(); + + @ManyToMany + @JoinTable(name = "subcategory_label", + joinColumns = @JoinColumn(name = "subcategory_id"), + inverseJoinColumns = @JoinColumn(name = "label_id")) + private List labels = new ArrayList<>(); + + public SubCategoryEntity() {} + + public SubCategoryEntity(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public List getCategories() { return categories; } + public List getLabels() { return labels; } + public void setLabels(List labels) { this.labels = labels; } +} diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/TagEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/TagEntity.java new file mode 100644 index 00000000..96b1a388 --- /dev/null +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/TagEntity.java @@ -0,0 +1,46 @@ +package dev.cerbos.queryplan.springdata.testmodel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "tags") +public class TagEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "pk") + private Long pk; + + @Column(name = "tag_id") + private String id; + + @Column(name = "name") + private String name; + + @ManyToOne + @JoinColumn(name = "resource_id") + private ResourceEntity resource; + + public TagEntity() {} + + public TagEntity(String id, String name, ResourceEntity resource) { + this.id = id; + this.name = name; + this.resource = resource; + } + + public Long getPk() { return pk; } + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public ResourceEntity getResource() { return resource; } + public void setResource(ResourceEntity resource) { this.resource = resource; } +} diff --git a/spring-data/src/test/resources/META-INF/persistence.xml b/spring-data/src/test/resources/META-INF/persistence.xml new file mode 100644 index 00000000..52f10ca1 --- /dev/null +++ b/spring-data/src/test/resources/META-INF/persistence.xml @@ -0,0 +1,29 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvider + dev.cerbos.queryplan.springdata.testmodel.ResourceEntity + dev.cerbos.queryplan.springdata.testmodel.TagEntity + dev.cerbos.queryplan.springdata.testmodel.CategoryEntity + dev.cerbos.queryplan.springdata.testmodel.SubCategoryEntity + dev.cerbos.queryplan.springdata.testmodel.LabelEntity + dev.cerbos.queryplan.springdata.testmodel.OwnerEntity + dev.cerbos.queryplan.springdata.testmodel.NestedEmbeddable + dev.cerbos.queryplan.springdata.testmodel.NextLevelEmbeddable + true + + + + + + + + + + + +