From 234b4ad4371035e13d77e01b7cfc3c498ea16ef3 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 14:33:37 +0200 Subject: [PATCH 01/28] Add E2E tests with caveat support and Docker Compose infrastructure - Add `e2e/docker-compose.yml` for SpiceDB + CockroachDB test infrastructure - Add `e2e/run-e2e.sh` convenience script for local E2E test execution - Add `e2e/schema-relationships.yaml` with caveat-based schema and test data - Add `SpiceDBE2ETest` validating time-gated access, inheritance, and lookups - Update `FgaSpiceDBQuery` to include caveat context when `at` timestamp is provided - Treat `PERMISSIONSHIP_CONDITIONAL_PERMISSION` as allowed in check results - Add `CaveatContextBuilder` tests for ISO-8601 timestamp serialization - Extend `SpiceDBSchemaWriter` with `writeCaveatRelationships()` for test fixtures - Add `at` parameter to `LookupResourcesRequest` and `LookupSubjectsRequest` - Add E2E job to GitHub Actions CI with SpiceDB readiness checks --- .github/workflows/ci.yaml | 34 +++ e2e/docker-compose.yml | 82 +++++++ e2e/run-e2e.sh | 45 ++++ e2e/schema-relationships.yaml | 45 ++++ pom.xml | 30 +++ .../internal/FeatureSpiceDBQuery.java | 3 +- .../internal/FgaSpiceDBQuery.java | 18 +- .../internal/LookupSpiceDBQuery.java | 27 ++- .../internal/PermissionSpiceDBQuery.java | 4 +- .../internal/RouteSpiceDBQuery.java | 3 +- .../model/EntityRequestContext.java | 32 ++- .../model/LookupResourcesRequest.java | 30 ++- .../model/LookupSubjectsRequest.java | 30 ++- .../sdk/entitlements/e2e/SpiceDBE2ETest.java | 212 ++++++++++++++++++ .../integration/SpiceDBIntegrationTest.java | 184 +++++++++++++++ .../integration/SpiceDBSchemaWriter.java | 98 ++++++++ .../internal/FgaSpiceDBQueryTest.java | 69 ++++++ .../internal/LookupSpiceDBQueryTest.java | 101 +++++++++ .../entitlements/model/LookupModelsTest.java | 63 ++++++ 19 files changed, 1088 insertions(+), 22 deletions(-) create mode 100644 e2e/docker-compose.yml create mode 100755 e2e/run-e2e.sh create mode 100644 e2e/schema-relationships.yaml create mode 100644 src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 17f09e6..a5fd914 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,6 +22,40 @@ jobs: run: mvn verify --batch-mode - name: Run integration tests run: mvn verify -P integration --batch-mode + e2e: + runs-on: ubuntu-latest + strategy: + matrix: + java: ['17'] + steps: + - uses: actions/checkout@v5 + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.java }} + distribution: temurin + cache: maven + - name: Start SpiceDB + run: docker compose -f e2e/docker-compose.yml up -d --wait + - name: Wait for SpiceDB readiness + run: | + for i in $(seq 1 30); do + if docker compose -f e2e/docker-compose.yml exec -T spicedb pgrep -f spicedb > /dev/null 2>&1; then + echo "SpiceDB is ready" + break + fi + if [ "$i" -eq 30 ]; then + echo "SpiceDB did not become ready" && exit 1 + fi + sleep 2 + done + - name: Build SDK + run: mvn compile test-compile --batch-mode + - name: Run E2E tests + run: mvn verify -P e2e --batch-mode -Dspicedb.endpoint=localhost:50051 -Dspicedb.token=spicedb + - name: Teardown + if: always() + run: docker compose -f e2e/docker-compose.yml down -v - name: Install core SDK to local repo run: mvn install --batch-mode -DskipTests - name: Compile Spring Boot Starter diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 0000000..070db3a --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,82 @@ +version: '3.8' + +services: + spicedb-cockroachdb: + image: cockroachdb/cockroach:latest + ports: + - '26257:26257' + volumes: + - spicedb-cockroachdb-data:/cockroach/cockroach-data + command: ['start-single-node', '--insecure'] + healthcheck: + test: ['CMD', '/cockroach/cockroach', 'sql', '--insecure', '--execute', 'SELECT 1'] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + environment: + - PGUSER=root + - PGPASSWORD=password + - PGDATABASE=spicedb + + spicedb-create-db: + image: cockroachdb/cockroach:latest + command: + [ + 'sql', + '--insecure', + '--host=spicedb-cockroachdb:26257', + '--execute=CREATE DATABASE IF NOT EXISTS spicedb;' + ] + depends_on: + spicedb-cockroachdb: + condition: service_healthy + restart: 'no' + + spicedb-migrate: + image: authzed/spicedb:v1.42.1 + command: + [ + 'datastore', + 'migrate', + 'head', + '--datastore-engine=cockroachdb', + '--datastore-conn-uri=postgres://root:password@spicedb-cockroachdb:26257/spicedb?sslmode=disable' + ] + depends_on: + spicedb-create-db: + condition: service_completed_successfully + restart: 'no' + + spicedb: + image: authzed/spicedb:v1.42.1 + cpus: 0.5 + mem_limit: 512m + ports: + - '50051:50051' + volumes: + - spicedb-data:/data + command: + [ + 'serve', + '--datastore-engine=cockroachdb', + '--datastore-conn-uri=postgres://root:password@spicedb-cockroachdb:26257/spicedb?sslmode=disable', + '--log-level=info' + ] + environment: + - SPICEDB_GRPC_PRESHARED_KEY=${SPICEDB_GRPC_PRESHARED_KEY:-spicedb} + healthcheck: + test: ['CMD', 'pgrep', '-f', 'spicedb'] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + depends_on: + spicedb-migrate: + condition: service_completed_successfully + spicedb-cockroachdb: + condition: service_healthy + +volumes: + spicedb-data: + spicedb-cockroachdb-data: diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh new file mode 100755 index 0000000..cbd2ed0 --- /dev/null +++ b/e2e/run-e2e.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cleanup() { + echo "Stopping SpiceDB infrastructure..." + docker compose -f "$SCRIPT_DIR/docker-compose.yml" down -v 2>/dev/null || true +} + +trap cleanup EXIT + +echo "Starting SpiceDB infrastructure..." +docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --wait + +echo "Waiting for SpiceDB to be ready..." +for i in $(seq 1 30); do + if docker compose -f "$SCRIPT_DIR/docker-compose.yml" exec -T spicedb pgrep -f spicedb > /dev/null 2>&1; then + echo "SpiceDB is ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: SpiceDB did not become ready in time." + exit 1 + fi + sleep 2 +done + +# Seed schema and relationships if zed CLI is available +if command -v zed &> /dev/null; then + echo "Seeding schema and relationships via zed..." + zed import "$SCRIPT_DIR/schema-relationships.yaml" +else + echo "zed CLI not found — schema will be seeded by the Java test @BeforeAll setup." + echo "Install zed: https://authzed.com/docs/spicedb/getting-started/installing-zed" +fi + +echo "Running E2E tests..." +cd "$PROJECT_DIR" +mvn verify -P e2e --batch-mode \ + -Dspicedb.endpoint=localhost:50051 \ + -Dspicedb.token=spicedb + +echo "E2E tests completed successfully." diff --git a/e2e/schema-relationships.yaml b/e2e/schema-relationships.yaml new file mode 100644 index 0000000..09ebe2b --- /dev/null +++ b/e2e/schema-relationships.yaml @@ -0,0 +1,45 @@ +schema: | + + caveat active_at(at timestamp, activeFrom any, activeUntil any) { + (activeFrom == null || at >= timestamp(activeFrom)) && (activeUntil == null || at <= timestamp(activeUntil)) + } + + definition frontegg_user {} + + definition frontegg_tenant {} + + definition frontegg_feature { + relation entitled: frontegg_user | frontegg_tenant + } + + definition frontegg_permission { + relation entitled: frontegg_user | frontegg_tenant + } + + definition frontegg_route { + relation entitled: frontegg_user | frontegg_tenant + } + + definition document { + relation viewer: frontegg_user + relation editor: frontegg_user + relation parent: folder | folder with active_at + relation reader: frontegg_user | frontegg_user with active_at + permission view = viewer + editor + permission edit = editor + permission read_doc = parent->read_folder + reader + } + + definition folder { + relation reader: frontegg_user | frontegg_user with active_at + permission read_folder = reader + } + +relationships: | + folder:c2FsYXJpZXM#reader@frontegg_user:QWxpY2U + document:VGltJ3Nfc2FsYXJ5X0phbg#reader@frontegg_user:VGlt[active_at:{"activeFrom":"2026-01-01T00:00:00.000Z","activeUntil":null}] + document:VGltJ3Nfc2FsYXJ5X0ZlYg#reader@frontegg_user:VGlt[active_at:{"activeFrom":"2026-02-01T00:00:00.000Z","activeUntil":null}] + document:VGltJ3Nfc2FsYXJ5X01hcg#reader@frontegg_user:VGlt[active_at:{"activeFrom":"2026-03-01T00:00:00.000Z","activeUntil":null}] + document:VGltJ3Nfc2FsYXJ5X0phbg#parent@folder:c2FsYXJpZXM[active_at:{"activeFrom":"2026-01-01T00:00:00.000Z","activeUntil":null}] + document:VGltJ3Nfc2FsYXJ5X0ZlYg#parent@folder:c2FsYXJpZXM[active_at:{"activeFrom":"2026-02-01T00:00:00.000Z","activeUntil":null}] + document:VGltJ3Nfc2FsYXJ5X01hcg#parent@folder:c2FsYXJpZXM[active_at:{"activeFrom":"2026-03-01T00:00:00.000Z","activeUntil":null}] diff --git a/pom.xml b/pom.xml index 88af3a3..ccf0e2f 100644 --- a/pom.xml +++ b/pom.xml @@ -194,6 +194,7 @@ false **/integration/** + **/e2e/** @@ -295,5 +296,34 @@ + + e2e + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + **/e2e/**Test.java + + + ${spicedb.endpoint} + ${spicedb.token} + + + + + + integration-test + verify + + + + + + + diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java index bdfb0be..a0e9419 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java @@ -92,7 +92,8 @@ EntitlementsResult query(UserSubjectContext userCtx, FeatureRequestContext featu boolean entitled = response.getPairsList().stream() .filter(CheckBulkPermissionsPair::hasItem) .map(pair -> pair.getItem().getPermissionship()) - .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION); + .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION + || p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); log.debug("Feature check result entitled={} userId={} featureKey={}", entitled, userCtx.userId(), featureCtx.featureKey()); diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java index e85176a..6c7bc37 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java @@ -7,6 +7,7 @@ import com.frontegg.sdk.entitlements.model.EntitlementsResult; import com.frontegg.sdk.entitlements.model.EntityRequestContext; import com.frontegg.sdk.entitlements.model.EntitySubjectContext; +import com.google.protobuf.Struct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,16 +68,25 @@ EntitlementsResult query(EntitySubjectContext entityCtx, EntityRequestContext re .setObjectId(b64ResourceId) .build(); - CheckPermissionRequest request = CheckPermissionRequest.newBuilder() + Struct caveatContext = CaveatContextBuilder.build(null, requestCtx.at()); + + CheckPermissionRequest.Builder requestBuilder = CheckPermissionRequest.newBuilder() .setSubject(subject) .setResource(resource) - .setPermission(requestCtx.relation()) - .build(); + .setPermission(requestCtx.relation()); + + if (caveatContext != null) { + requestBuilder.setContext(caveatContext); + } + + CheckPermissionRequest request = requestBuilder.build(); CheckPermissionResponse response = executor.execute(request); boolean allowed = response.getPermissionship() - == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION; + == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION + || response.getPermissionship() + == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION; log.debug("FGA check result allowed={} entityType={} entityId={} resourceType={} resourceId={} relation={}", allowed, diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java index e49ffab..b930b17 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java @@ -7,6 +7,7 @@ import com.frontegg.sdk.entitlements.model.LookupResourcesRequest; import com.frontegg.sdk.entitlements.model.LookupResult; import com.frontegg.sdk.entitlements.model.LookupSubjectsRequest; +import com.google.protobuf.Struct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,7 +53,9 @@ LookupResult lookupResources(LookupResourcesRequest request) { request.subjectType(), request.subjectId(), request.permission(), request.resourceType()); - com.authzed.api.v1.LookupResourcesRequest grpcRequest = + Struct caveatContext = CaveatContextBuilder.build(null, request.at()); + + com.authzed.api.v1.LookupResourcesRequest.Builder grpcRequestBuilder = com.authzed.api.v1.LookupResourcesRequest.newBuilder() .setSubject(SubjectReference.newBuilder() .setObject(ObjectReference.newBuilder() @@ -61,8 +64,13 @@ LookupResult lookupResources(LookupResourcesRequest request) { .build()) .build()) .setResourceObjectType(request.resourceType()) - .setPermission(request.permission()) - .build(); + .setPermission(request.permission()); + + if (caveatContext != null) { + grpcRequestBuilder.setContext(caveatContext); + } + + com.authzed.api.v1.LookupResourcesRequest grpcRequest = grpcRequestBuilder.build(); Iterator responseIterator = lookupResourcesExecutor.execute(grpcRequest); @@ -92,15 +100,22 @@ LookupResult lookupSubjects(LookupSubjectsRequest request) { request.resourceType(), request.resourceId(), request.permission(), request.subjectType()); - com.authzed.api.v1.LookupSubjectsRequest grpcRequest = + Struct caveatContext = CaveatContextBuilder.build(null, request.at()); + + com.authzed.api.v1.LookupSubjectsRequest.Builder grpcRequestBuilder = com.authzed.api.v1.LookupSubjectsRequest.newBuilder() .setResource(ObjectReference.newBuilder() .setObjectType(request.resourceType()) .setObjectId(b64ResourceId) .build()) .setPermission(request.permission()) - .setSubjectObjectType(request.subjectType()) - .build(); + .setSubjectObjectType(request.subjectType()); + + if (caveatContext != null) { + grpcRequestBuilder.setContext(caveatContext); + } + + com.authzed.api.v1.LookupSubjectsRequest grpcRequest = grpcRequestBuilder.build(); Iterator responseIterator = lookupSubjectsExecutor.execute(grpcRequest); diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java index fc59ba9..a86e265 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java @@ -101,7 +101,9 @@ EntitlementsResult query(UserSubjectContext userCtx, PermissionRequestContext pe Set entitledPermissionIds = response.getPairsList().stream() .filter(CheckBulkPermissionsPair::hasItem) .filter(pair -> pair.getItem().getPermissionship() - == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION) + == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION + || pair.getItem().getPermissionship() + == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION) .map(pair -> pair.getRequest().getResource().getObjectId()) .collect(Collectors.toSet()); diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java index 5c0ac91..20cbf25 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java @@ -98,7 +98,8 @@ EntitlementsResult query(UserSubjectContext userCtx, RouteRequestContext routeCt boolean entitled = response.getPairsList().stream() .filter(CheckBulkPermissionsPair::hasItem) .map(pair -> pair.getItem().getPermissionship()) - .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION); + .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION + || p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); log.debug("Route check result entitled={} userId={} method={} path={}", entitled, userCtx.userId(), routeCtx.method(), routeCtx.path()); diff --git a/src/main/java/com/frontegg/sdk/entitlements/model/EntityRequestContext.java b/src/main/java/com/frontegg/sdk/entitlements/model/EntityRequestContext.java index b3a6ef5..03cc433 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/model/EntityRequestContext.java +++ b/src/main/java/com/frontegg/sdk/entitlements/model/EntityRequestContext.java @@ -1,5 +1,6 @@ package com.frontegg.sdk.entitlements.model; +import java.time.Instant; import java.util.Objects; /** @@ -9,9 +10,18 @@ *

The authorization engine checks whether the subject has the specified {@code relation} on * the resource identified by {@code resourceType} and {@code resourceId}. * + *

The optional {@code at} parameter allows checking entitlement at a specific point in time + * (past or future). When {@code at} is non-null it is forwarded to the authorization engine as + * an {@code "at"} field in the caveat context using ISO-8601 string format. A {@code null} + * value means "check at the current time". + * *

{@code
  * // Check whether the current entity can "view" a specific document
  * RequestContext request = new EntityRequestContext("document", "doc-789", "viewer");
+ *
+ * // Check at a specific point in time
+ * RequestContext request = new EntityRequestContext("document", "doc-789", "viewer",
+ *         Instant.parse("2026-01-01T00:00:00Z"));
  * }
* * @param resourceType the type name of the resource as registered in the SpiceDB schema (e.g. @@ -19,19 +29,23 @@ * @param resourceId the unique identifier of the resource instance; must not be null or blank * @param relation the relation to check (e.g. {@code "viewer"}, {@code "editor"}); * must not be null or blank + * @param at the point in time at which to evaluate entitlement; {@code null} means + * "check at the current time" * @since 0.1.0 */ public record EntityRequestContext( String resourceType, String resourceId, - String relation + String relation, + Instant at ) implements RequestContext { /** * Compact canonical constructor — validates that no field is null or blank. + * {@code at} is nullable; {@code null} means "check at the current time". * - * @throws NullPointerException if any field is {@code null} - * @throws IllegalArgumentException if any field is blank + * @throws NullPointerException if {@code resourceType}, {@code resourceId}, or {@code relation} is {@code null} + * @throws IllegalArgumentException if any required field is blank */ public EntityRequestContext { Objects.requireNonNull(resourceType, "resourceType must not be null"); @@ -46,5 +60,17 @@ public record EntityRequestContext( if (relation.isBlank()) { throw new IllegalArgumentException("relation must not be blank"); } + // at is nullable — null means "check at current time" + } + + /** + * Convenience constructor for checking at the current time. + * + * @param resourceType the resource type; must not be null or blank + * @param resourceId the resource id; must not be null or blank + * @param relation the relation to check; must not be null or blank + */ + public EntityRequestContext(String resourceType, String resourceId, String relation) { + this(resourceType, resourceId, relation, null); } } diff --git a/src/main/java/com/frontegg/sdk/entitlements/model/LookupResourcesRequest.java b/src/main/java/com/frontegg/sdk/entitlements/model/LookupResourcesRequest.java index 26b7cf2..6d2c1db 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/model/LookupResourcesRequest.java +++ b/src/main/java/com/frontegg/sdk/entitlements/model/LookupResourcesRequest.java @@ -1,5 +1,6 @@ package com.frontegg.sdk.entitlements.model; +import java.time.Instant; import java.util.Objects; /** @@ -9,27 +10,50 @@ * id) and a permission name, returns all resources of the specified type on which the subject * holds that permission. * + *

The optional {@code at} parameter allows looking up resources at a specific point in time + * (past or future). When {@code at} is non-null it is forwarded to the authorization engine as + * an {@code "at"} field in the caveat context using ISO-8601 string format. A {@code null} + * value means "check at the current time". + * * @param subjectType the type of the subject (e.g. {@code "frontegg_user"}) * @param subjectId the subject's identifier * @param permission the permission/relation to check (e.g. {@code "entitled"}, {@code "viewer"}) * @param resourceType the type of resources to look up (e.g. {@code "frontegg_feature"}) + * @param at the point in time at which to evaluate entitlement; {@code null} means + * "check at the current time" * @since 0.2.0 */ public record LookupResourcesRequest( String subjectType, String subjectId, String permission, - String resourceType + String resourceType, + Instant at ) { /** - * Compact canonical constructor — validates that no field is null. + * Compact canonical constructor — validates that required fields are not null. + * {@code at} is nullable; {@code null} means "check at the current time". * - * @throws NullPointerException if any field is {@code null} + * @throws NullPointerException if any required field is {@code null} */ public LookupResourcesRequest { Objects.requireNonNull(subjectType, "subjectType must not be null"); Objects.requireNonNull(subjectId, "subjectId must not be null"); Objects.requireNonNull(permission, "permission must not be null"); Objects.requireNonNull(resourceType, "resourceType must not be null"); + // at is nullable — null means "check at current time" + } + + /** + * Convenience constructor for looking up resources at the current time. + * + * @param subjectType the type of the subject + * @param subjectId the subject's identifier + * @param permission the permission/relation to check + * @param resourceType the type of resources to look up + */ + public LookupResourcesRequest(String subjectType, String subjectId, + String permission, String resourceType) { + this(subjectType, subjectId, permission, resourceType, null); } } diff --git a/src/main/java/com/frontegg/sdk/entitlements/model/LookupSubjectsRequest.java b/src/main/java/com/frontegg/sdk/entitlements/model/LookupSubjectsRequest.java index aa66faf..b2f54c6 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/model/LookupSubjectsRequest.java +++ b/src/main/java/com/frontegg/sdk/entitlements/model/LookupSubjectsRequest.java @@ -1,5 +1,6 @@ package com.frontegg.sdk.entitlements.model; +import java.time.Instant; import java.util.Objects; /** @@ -9,27 +10,50 @@ * id) and a permission name, returns all subjects of the specified type that hold that * permission on the resource. * + *

The optional {@code at} parameter allows looking up subjects at a specific point in time + * (past or future). When {@code at} is non-null it is forwarded to the authorization engine as + * an {@code "at"} field in the caveat context using ISO-8601 string format. A {@code null} + * value means "check at the current time". + * * @param resourceType the type of the resource (e.g. {@code "document"}) * @param resourceId the resource's identifier * @param permission the permission/relation to check (e.g. {@code "viewer"}) * @param subjectType the type of subjects to look up (e.g. {@code "frontegg_user"}) + * @param at the point in time at which to evaluate entitlement; {@code null} means + * "check at the current time" * @since 0.2.0 */ public record LookupSubjectsRequest( String resourceType, String resourceId, String permission, - String subjectType + String subjectType, + Instant at ) { /** - * Compact canonical constructor — validates that no field is null. + * Compact canonical constructor — validates that required fields are not null. + * {@code at} is nullable; {@code null} means "check at the current time". * - * @throws NullPointerException if any field is {@code null} + * @throws NullPointerException if any required field is {@code null} */ public LookupSubjectsRequest { Objects.requireNonNull(resourceType, "resourceType must not be null"); Objects.requireNonNull(resourceId, "resourceId must not be null"); Objects.requireNonNull(permission, "permission must not be null"); Objects.requireNonNull(subjectType, "subjectType must not be null"); + // at is nullable — null means "check at current time" + } + + /** + * Convenience constructor for looking up subjects at the current time. + * + * @param resourceType the type of the resource + * @param resourceId the resource's identifier + * @param permission the permission/relation to check + * @param subjectType the type of subjects to look up + */ + public LookupSubjectsRequest(String resourceType, String resourceId, + String permission, String subjectType) { + this(resourceType, resourceId, permission, subjectType, null); } } diff --git a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java new file mode 100644 index 0000000..5aca849 --- /dev/null +++ b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java @@ -0,0 +1,212 @@ +package com.frontegg.sdk.entitlements.e2e; + +import com.frontegg.sdk.entitlements.EntitlementsClient; +import com.frontegg.sdk.entitlements.EntitlementsClientFactory; +import com.frontegg.sdk.entitlements.config.ClientConfiguration; +import com.frontegg.sdk.entitlements.integration.SpiceDBSchemaWriter; +import com.frontegg.sdk.entitlements.model.EntitlementsResult; +import com.frontegg.sdk.entitlements.model.EntityRequestContext; +import com.frontegg.sdk.entitlements.model.EntitySubjectContext; +import com.frontegg.sdk.entitlements.model.LookupResourcesRequest; +import com.frontegg.sdk.entitlements.model.LookupResult; +import com.frontegg.sdk.entitlements.model.LookupSubjectsRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end tests that run against an external SpiceDB instance (e.g. via docker-compose). + * + *

These tests exercise the same caveat/inheritance scenarios as the Node.js SDK demo, + * validating full parity between the Java and Node.js SDKs. + * + *

Run with: {@code mvn verify -P e2e -Dspicedb.endpoint=localhost:50051 -Dspicedb.token=spicedb} + *

Or use the convenience script: {@code ./e2e/run-e2e.sh} + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class SpiceDBE2ETest { + + static EntitlementsClient client; + static SpiceDBSchemaWriter schemaWriter; + + @BeforeAll + static void setup() { + String endpoint = System.getProperty("spicedb.endpoint", "localhost:50051"); + String token = System.getProperty("spicedb.token", "spicedb"); + + // Seed schema and relationships via gRPC (idempotent) + schemaWriter = new SpiceDBSchemaWriter(endpoint, token); + schemaWriter.writeSchema(); + schemaWriter.writeRelationships(); + schemaWriter.writeCaveatRelationships(); + + ClientConfiguration config = ClientConfiguration.builder() + .engineEndpoint(endpoint) + .engineToken(token) + .useTls(false) + .build(); + + client = EntitlementsClientFactory.create(config); + } + + @AfterAll + static void teardown() { + if (client != null) { + client.close(); + } + if (schemaWriter != null) { + schemaWriter.close(); + } + } + + // ------------------------------------------------------------------------- + // Demo 1: Alice reads Tim's salary via folder inheritance + // ------------------------------------------------------------------------- + + @Test + @Order(1) + void alice_canReadTimsSalaryViaFolderInheritance() { + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Alice"), + new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", + Instant.parse("2026-02-01T00:00:00Z"))); + assertTrue(result.result(), + "Alice should read Tim's Jan salary doc via folder inheritance"); + } + + @Test + @Order(2) + void alice_cannotReadBeforeParentActivation() { + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Alice"), + new EntityRequestContext("document", "Tim's_salary_Feb", "read_doc", + Instant.parse("2026-01-15T00:00:00Z"))); + assertFalse(result.result(), + "Alice should be denied Feb salary doc before parent folder activation"); + } + + // ------------------------------------------------------------------------- + // Demo 2: Tim reads his own salary (direct, time-gated) + // ------------------------------------------------------------------------- + + @Test + @Order(10) + void tim_canReadJanSalaryAtJan() { + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", + Instant.parse("2026-01-01T00:00:00Z"))); + assertTrue(result.result(), + "Tim should read Jan salary doc at 2026-01-01"); + } + + @Test + @Order(11) + void tim_cannotReadFebSalaryAtJan() { + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Feb", "read_doc", + Instant.parse("2026-01-01T00:00:00Z"))); + assertFalse(result.result(), + "Tim should be denied Feb salary doc at 2026-01-01"); + } + + @Test + @Order(12) + void tim_canReadFebSalaryAtFeb() { + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Feb", "read_doc", + Instant.parse("2026-02-01T00:00:00Z"))); + assertTrue(result.result(), + "Tim should read Feb salary doc at 2026-02-01"); + } + + @Test + @Order(13) + void tim_canReadMarSalaryAtMar() { + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Mar", "read_doc", + Instant.parse("2026-03-01T00:00:00Z"))); + assertTrue(result.result(), + "Tim should read Mar salary doc at 2026-03-01"); + } + + // ------------------------------------------------------------------------- + // Demo 3: Lookup documents Tim can read at different times + // ------------------------------------------------------------------------- + + @Test + @Order(20) + void lookupResources_timAtJan_onlyJanDoc() { + LookupResult result = client.lookupResources( + new LookupResourcesRequest("frontegg_user", "Tim", "read_doc", "document", + Instant.parse("2026-01-01T00:00:00Z"))); + assertNotNull(result); + assertTrue(result.entityIds().contains("Tim's_salary_Jan")); + assertFalse(result.entityIds().contains("Tim's_salary_Feb")); + assertFalse(result.entityIds().contains("Tim's_salary_Mar")); + } + + @Test + @Order(21) + void lookupResources_timAtFeb_janAndFebDocs() { + LookupResult result = client.lookupResources( + new LookupResourcesRequest("frontegg_user", "Tim", "read_doc", "document", + Instant.parse("2026-02-01T00:00:00Z"))); + assertNotNull(result); + assertTrue(result.entityIds().contains("Tim's_salary_Jan")); + assertTrue(result.entityIds().contains("Tim's_salary_Feb")); + assertFalse(result.entityIds().contains("Tim's_salary_Mar")); + } + + @Test + @Order(22) + void lookupResources_timAtMar_allThreeDocs() { + LookupResult result = client.lookupResources( + new LookupResourcesRequest("frontegg_user", "Tim", "read_doc", "document", + Instant.parse("2026-03-01T00:00:00Z"))); + assertNotNull(result); + assertTrue(result.entityIds().contains("Tim's_salary_Jan")); + assertTrue(result.entityIds().contains("Tim's_salary_Feb")); + assertTrue(result.entityIds().contains("Tim's_salary_Mar")); + } + + // ------------------------------------------------------------------------- + // Demo 4: Lookup users who can read a specific document + // ------------------------------------------------------------------------- + + @Test + @Order(30) + void lookupSubjects_janDocAtFeb_returnsTimAndAlice() { + LookupResult result = client.lookupSubjects( + new LookupSubjectsRequest("document", "Tim's_salary_Jan", "read_doc", + "frontegg_user", Instant.parse("2026-02-01T00:00:00Z"))); + assertNotNull(result); + assertTrue(result.entityIds().contains("Tim"), + "Tim should be a reader of Jan salary doc"); + assertTrue(result.entityIds().contains("Alice"), + "Alice should be a reader of Jan salary doc via folder"); + } + + @Test + @Order(31) + void lookupSubjects_febDocBeforeFeb_returnsNoOne() { + LookupResult result = client.lookupSubjects( + new LookupSubjectsRequest("document", "Tim's_salary_Feb", "read_doc", + "frontegg_user", Instant.parse("2026-01-15T00:00:00Z"))); + assertNotNull(result); + assertFalse(result.entityIds().contains("Tim")); + assertFalse(result.entityIds().contains("Alice")); + } +} diff --git a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBIntegrationTest.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBIntegrationTest.java index 12f4fde..92167e1 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBIntegrationTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBIntegrationTest.java @@ -13,6 +13,8 @@ import com.frontegg.sdk.entitlements.model.PermissionRequestContext; import com.frontegg.sdk.entitlements.model.RouteRequestContext; import com.frontegg.sdk.entitlements.model.UserSubjectContext; + +import java.time.Instant; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.MethodOrderer; @@ -65,6 +67,7 @@ static void setup() { spicedb.getGrpcEndpoint(), spicedb.getPresharedKey()); schemaWriter.writeSchema(); schemaWriter.writeRelationships(); + schemaWriter.writeCaveatRelationships(); ClientConfiguration config = ClientConfiguration.builder() .engineEndpoint(spicedb.getGrpcEndpoint()) @@ -367,6 +370,187 @@ void lookupSubjects_unknownDocument_returnsEmpty() { "unknown document should have no subjects"); } + // ------------------------------------------------------------------------- + // Caveat-based FGA checks (time-gated access — ported from Node.js demo) + // ------------------------------------------------------------------------- + + @Test + @Order(70) + void caveatFga_timDirectReader_entitledAfterActiveFrom() { + // Tim is direct reader of Jan salary doc with activeFrom=2026-01-01 + // Checking at 2026-01-01 should be entitled + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", + Instant.parse("2026-01-01T00:00:00Z"))); + assertTrue(result.result(), + "Tim should be entitled to read Jan salary doc at 2026-01-01 (activeFrom)"); + } + + @Test + @Order(71) + void caveatFga_timDirectReader_deniedBeforeActiveFrom() { + // Tim is direct reader of Feb salary doc with activeFrom=2026-02-01 + // Checking at 2026-01-01 should be denied (before activeFrom) + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Feb", "read_doc", + Instant.parse("2026-01-01T00:00:00Z"))); + assertFalse(result.result(), + "Tim should be denied Feb salary doc at 2026-01-01 (before activeFrom of 2026-02-01)"); + } + + @Test + @Order(72) + void caveatFga_timDirectReader_entitledAtActiveFrom() { + // Tim is direct reader of Feb salary doc with activeFrom=2026-02-01 + // Checking at 2026-02-01 should be entitled + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Feb", "read_doc", + Instant.parse("2026-02-01T00:00:00Z"))); + assertTrue(result.result(), + "Tim should be entitled to read Feb salary doc at 2026-02-01 (exact activeFrom)"); + } + + @Test + @Order(73) + void caveatFga_timDirectReader_marchDocEntitledAtMarch() { + // Tim is direct reader of Mar salary doc with activeFrom=2026-03-01 + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Mar", "read_doc", + Instant.parse("2026-03-01T00:00:00Z"))); + assertTrue(result.result(), + "Tim should be entitled to read Mar salary doc at 2026-03-01"); + } + + @Test + @Order(74) + void caveatFga_timDirectReader_marchDocDeniedBeforeMarch() { + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Mar", "read_doc", + Instant.parse("2026-02-15T00:00:00Z"))); + assertFalse(result.result(), + "Tim should be denied Mar salary doc at 2026-02-15 (before activeFrom)"); + } + + // ------------------------------------------------------------------------- + // Permission inheritance through folder (Alice reads via folder membership) + // ------------------------------------------------------------------------- + + @Test + @Order(75) + void caveatFga_aliceInheritance_canReadDocViaFolder() { + // Alice is reader of "salaries" folder (no caveat) + // Jan salary doc has "salaries" folder as parent with activeFrom=2026-01-01 + // At 2026-02-01, the parent relation is active → Alice inherits read_doc + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Alice"), + new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", + Instant.parse("2026-02-01T00:00:00Z"))); + assertTrue(result.result(), + "Alice should read Jan salary doc via folder inheritance at 2026-02-01"); + } + + @Test + @Order(76) + void caveatFga_aliceInheritance_deniedBeforeParentActiveFrom() { + // Feb salary doc parent has activeFrom=2026-02-01 + // At 2026-01-15, the parent relation is not yet active → Alice cannot inherit + EntitlementsResult result = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Alice"), + new EntityRequestContext("document", "Tim's_salary_Feb", "read_doc", + Instant.parse("2026-01-15T00:00:00Z"))); + assertFalse(result.result(), + "Alice should be denied Feb salary doc at 2026-01-15 (parent not yet active)"); + } + + // ------------------------------------------------------------------------- + // Time-filtered lookupResources + // ------------------------------------------------------------------------- + + @Test + @Order(80) + void lookupResources_withAt_returnsOnlyTimeValidDocs() { + // Tim at 2026-01-01 should only be able to read the Jan salary doc + LookupResult result = client.lookupResources( + new LookupResourcesRequest("frontegg_user", "Tim", "read_doc", "document", + Instant.parse("2026-01-01T00:00:00Z"))); + assertNotNull(result); + assertTrue(result.entityIds().contains("Tim's_salary_Jan"), + "Jan doc should be in results at 2026-01-01"); + assertFalse(result.entityIds().contains("Tim's_salary_Feb"), + "Feb doc should NOT be in results at 2026-01-01"); + assertFalse(result.entityIds().contains("Tim's_salary_Mar"), + "Mar doc should NOT be in results at 2026-01-01"); + } + + @Test + @Order(81) + void lookupResources_withAt_february_returnsJanAndFeb() { + // Tim at 2026-02-01 should be able to read Jan and Feb salary docs + LookupResult result = client.lookupResources( + new LookupResourcesRequest("frontegg_user", "Tim", "read_doc", "document", + Instant.parse("2026-02-01T00:00:00Z"))); + assertNotNull(result); + assertTrue(result.entityIds().contains("Tim's_salary_Jan"), + "Jan doc should be in results at 2026-02-01"); + assertTrue(result.entityIds().contains("Tim's_salary_Feb"), + "Feb doc should be in results at 2026-02-01"); + assertFalse(result.entityIds().contains("Tim's_salary_Mar"), + "Mar doc should NOT be in results at 2026-02-01"); + } + + @Test + @Order(82) + void lookupResources_withAt_march_returnsAllThree() { + // Tim at 2026-03-01 should be able to read all three salary docs + LookupResult result = client.lookupResources( + new LookupResourcesRequest("frontegg_user", "Tim", "read_doc", "document", + Instant.parse("2026-03-01T00:00:00Z"))); + assertNotNull(result); + assertTrue(result.entityIds().contains("Tim's_salary_Jan"), + "Jan doc should be in results at 2026-03-01"); + assertTrue(result.entityIds().contains("Tim's_salary_Feb"), + "Feb doc should be in results at 2026-03-01"); + assertTrue(result.entityIds().contains("Tim's_salary_Mar"), + "Mar doc should be in results at 2026-03-01"); + } + + // ------------------------------------------------------------------------- + // Time-filtered lookupSubjects + // ------------------------------------------------------------------------- + + @Test + @Order(85) + void lookupSubjects_withAt_janDoc_returnsTimAndAlice() { + // At 2026-02-01: Tim is direct reader (activeFrom=Jan), Alice inherits via folder (parent activeFrom=Jan) + LookupResult result = client.lookupSubjects( + new LookupSubjectsRequest("document", "Tim's_salary_Jan", "read_doc", "frontegg_user", + Instant.parse("2026-02-01T00:00:00Z"))); + assertNotNull(result); + assertTrue(result.entityIds().contains("Tim"), + "Tim should be a reader of Jan salary doc at 2026-02-01"); + assertTrue(result.entityIds().contains("Alice"), + "Alice should be a reader of Jan salary doc at 2026-02-01 via folder inheritance"); + } + + @Test + @Order(86) + void lookupSubjects_withAt_febDocBeforeFeb_excludesTimAndAlice() { + // At 2026-01-15: Feb doc has activeFrom=2026-02-01 for both Tim's reader and folder parent + LookupResult result = client.lookupSubjects( + new LookupSubjectsRequest("document", "Tim's_salary_Feb", "read_doc", "frontegg_user", + Instant.parse("2026-01-15T00:00:00Z"))); + assertNotNull(result); + assertFalse(result.entityIds().contains("Tim"), + "Tim should NOT be a reader of Feb salary doc at 2026-01-15"); + assertFalse(result.entityIds().contains("Alice"), + "Alice should NOT be a reader of Feb salary doc at 2026-01-15"); + } + // ------------------------------------------------------------------------- // Client lifecycle // ------------------------------------------------------------------------- diff --git a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java index f0618e7..e19dd27 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java +++ b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java @@ -1,5 +1,6 @@ package com.frontegg.sdk.entitlements.integration; +import com.authzed.api.v1.ContextualizedCaveat; import com.authzed.api.v1.ObjectReference; import com.authzed.api.v1.PermissionsServiceGrpc; import com.authzed.api.v1.Relationship; @@ -9,6 +10,8 @@ import com.authzed.api.v1.WriteRelationshipsRequest; import com.authzed.api.v1.WriteSchemaRequest; import com.authzed.grpcutil.BearerToken; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; @@ -46,6 +49,10 @@ public SpiceDBSchemaWriter(String endpoint, String token) { */ public void writeSchema() { String schema = """ + caveat active_at(at timestamp, activeFrom any, activeUntil any) { + (activeFrom == null || at >= timestamp(activeFrom)) && (activeUntil == null || at <= timestamp(activeUntil)) + } + definition frontegg_user {} definition frontegg_tenant {} @@ -65,8 +72,16 @@ public void writeSchema() { definition document { relation viewer: frontegg_user relation editor: frontegg_user + relation parent: folder | folder with active_at + relation reader: frontegg_user | frontegg_user with active_at permission view = viewer + editor permission edit = editor + permission read_doc = parent->read_folder + reader + } + + definition folder { + relation reader: frontegg_user | frontegg_user with active_at + permission read_folder = reader } """; @@ -118,6 +133,52 @@ public void writeRelationships() { permissionsStub.writeRelationships(request); } + /** + * Writes caveat-based relationships for time-gated access tests. + * + *

Ported from the Node.js SDK demo. Sets up: + *

    + *
  • Alice is a reader of the "salaries" folder (no caveat — always allowed)
  • + *
  • Tim is a direct reader of salary docs (with active_at caveat per month)
  • + *
  • Salary docs have "salaries" folder as parent (with active_at caveat per month)
  • + *
+ * + *

This enables testing: + *

    + *
  • Time-based direct access (Tim can read doc at/after activeFrom)
  • + *
  • Permission inheritance through folder (Alice can read docs via folder reader)
  • + *
  • Time-filtered lookups (lookupResources/lookupSubjects with at parameter)
  • + *
+ */ + public void writeCaveatRelationships() { + WriteRelationshipsRequest request = WriteRelationshipsRequest.newBuilder() + // Alice is always a reader of the salaries folder (no caveat) + .addUpdates(buildUpdate("folder", encode("salaries"), "reader", + "frontegg_user", encode("Alice"))) + // Tim is a direct reader of salary docs with time-based caveats + .addUpdates(buildCaveatUpdate("document", encode("Tim's_salary_Jan"), "reader", + "frontegg_user", encode("Tim"), + "active_at", "2026-01-01T00:00:00.000Z", null)) + .addUpdates(buildCaveatUpdate("document", encode("Tim's_salary_Feb"), "reader", + "frontegg_user", encode("Tim"), + "active_at", "2026-02-01T00:00:00.000Z", null)) + .addUpdates(buildCaveatUpdate("document", encode("Tim's_salary_Mar"), "reader", + "frontegg_user", encode("Tim"), + "active_at", "2026-03-01T00:00:00.000Z", null)) + // Salary docs have "salaries" folder as parent (with time-based caveats) + .addUpdates(buildCaveatUpdate("document", encode("Tim's_salary_Jan"), "parent", + "folder", encode("salaries"), + "active_at", "2026-01-01T00:00:00.000Z", null)) + .addUpdates(buildCaveatUpdate("document", encode("Tim's_salary_Feb"), "parent", + "folder", encode("salaries"), + "active_at", "2026-02-01T00:00:00.000Z", null)) + .addUpdates(buildCaveatUpdate("document", encode("Tim's_salary_Mar"), "parent", + "folder", encode("salaries"), + "active_at", "2026-03-01T00:00:00.000Z", null)) + .build(); + permissionsStub.writeRelationships(request); + } + private static RelationshipUpdate buildUpdate(String resourceType, String resourceId, String relation, String subjectType, String subjectId) { @@ -139,6 +200,43 @@ private static RelationshipUpdate buildUpdate(String resourceType, String resour .build(); } + private static RelationshipUpdate buildCaveatUpdate(String resourceType, String resourceId, + String relation, + String subjectType, String subjectId, + String caveatName, String activeFrom, + String activeUntil) { + Struct.Builder contextBuilder = Struct.newBuilder(); + if (activeFrom != null) { + contextBuilder.putFields("activeFrom", + Value.newBuilder().setStringValue(activeFrom).build()); + } + if (activeUntil != null) { + contextBuilder.putFields("activeUntil", + Value.newBuilder().setStringValue(activeUntil).build()); + } + + return RelationshipUpdate.newBuilder() + .setOperation(RelationshipUpdate.Operation.OPERATION_TOUCH) + .setRelationship(Relationship.newBuilder() + .setResource(ObjectReference.newBuilder() + .setObjectType(resourceType) + .setObjectId(resourceId) + .build()) + .setRelation(relation) + .setSubject(SubjectReference.newBuilder() + .setObject(ObjectReference.newBuilder() + .setObjectType(subjectType) + .setObjectId(subjectId) + .build()) + .build()) + .setOptionalCaveat(ContextualizedCaveat.newBuilder() + .setCaveatName(caveatName) + .setContext(contextBuilder.build()) + .build()) + .build()) + .build(); + } + /** * Encodes a plain-text ID to URL-safe Base64 without padding, matching * {@code Base64Utils.encode()} in the SDK's internal package. diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java index a46ad14..2f007d5 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java @@ -205,6 +205,75 @@ void query_base64Encoding_unicodeEntityId_encodedCorrectly() { "resource id with slash must be UTF-8 encoded then base64'd"); } + // ------------------------------------------------------------------------- + // Caveat context — time-based access checks + // ------------------------------------------------------------------------- + + @Test + void query_withAtTimestamp_requestIncludesCaveatContext() { + AtomicReference captured = new AtomicReference<>(); + + FgaSpiceDBQuery query = new FgaSpiceDBQuery(req -> { + captured.set(req); + return permissionResponse( + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION); + }); + + java.time.Instant at = java.time.Instant.parse("2026-02-01T00:00:00Z"); + query.query( + new EntitySubjectContext("user", "Tim"), + new EntityRequestContext("document", "doc-1", "read_doc", at)); + + CheckPermissionRequest request = captured.get(); + assertNotNull(request); + assertTrue(request.hasContext(), "request must include caveat context when at is provided"); + assertEquals(at.toString(), + request.getContext().getFieldsOrThrow("at").getStringValue(), + "caveat context 'at' field must contain ISO-8601 timestamp"); + } + + @Test + void query_withNullAt_requestHasNoCaveatContext() { + AtomicReference captured = new AtomicReference<>(); + + FgaSpiceDBQuery query = new FgaSpiceDBQuery(req -> { + captured.set(req); + return permissionResponse( + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION); + }); + + query.query( + new EntitySubjectContext("user", "Alice"), + new EntityRequestContext("document", "doc-1", "read_doc")); + + CheckPermissionRequest request = captured.get(); + assertNotNull(request); + assertFalse(request.hasContext(), + "request must not include caveat context when at is null"); + } + + @Test + void query_withAtTimestamp_caveatContextUsesIso8601Format() { + AtomicReference captured = new AtomicReference<>(); + + FgaSpiceDBQuery query = new FgaSpiceDBQuery(req -> { + captured.set(req); + return permissionResponse( + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION); + }); + + java.time.Instant at = java.time.Instant.parse("2026-03-15T14:30:00Z"); + query.query( + new EntitySubjectContext("service_account", "svc-01"), + new EntityRequestContext("project", "proj-1", "editor", at)); + + CheckPermissionRequest request = captured.get(); + String atValue = request.getContext().getFieldsOrThrow("at").getStringValue(); + // Instant.toString() produces ISO-8601: "2026-03-15T14:30:00Z" + assertEquals("2026-03-15T14:30:00Z", atValue, + "caveat context at value must be ISO-8601 format"); + } + // ------------------------------------------------------------------------- // Helper factory methods // ------------------------------------------------------------------------- diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQueryTest.java index ad8961e..7a69b4a 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQueryTest.java @@ -17,6 +17,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -231,6 +232,106 @@ void lookupSubjects_requestConstruction_resourceTypeAndIdAreEncoded() { "permission must match the request permission"); } + // ------------------------------------------------------------------------- + // lookupResources — caveat context (time-based access) + // ------------------------------------------------------------------------- + + @Test + void lookupResources_withAtTimestamp_requestIncludesCaveatContext() { + AtomicReference captured = new AtomicReference<>(); + + LookupSpiceDBQuery query = new LookupSpiceDBQuery( + req -> { + captured.set(req); + return Collections.emptyIterator(); + }, + req -> Collections.emptyIterator()); + + java.time.Instant at = java.time.Instant.parse("2026-03-01T00:00:00Z"); + query.lookupResources( + new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( + "user", "Tim", "read_doc", "document", at)); + + LookupResourcesRequest grpcRequest = captured.get(); + assertNotNull(grpcRequest); + assertTrue(grpcRequest.hasContext(), + "gRPC request must include caveat context when at is provided"); + assertEquals(at.toString(), + grpcRequest.getContext().getFieldsOrThrow("at").getStringValue(), + "caveat context 'at' field must contain ISO-8601 timestamp"); + } + + @Test + void lookupResources_withNullAt_requestHasNoCaveatContext() { + AtomicReference captured = new AtomicReference<>(); + + LookupSpiceDBQuery query = new LookupSpiceDBQuery( + req -> { + captured.set(req); + return Collections.emptyIterator(); + }, + req -> Collections.emptyIterator()); + + query.lookupResources( + new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( + "user", "Tim", "read_doc", "document")); + + LookupResourcesRequest grpcRequest = captured.get(); + assertNotNull(grpcRequest); + assertFalse(grpcRequest.hasContext(), + "gRPC request must not include caveat context when at is null"); + } + + // ------------------------------------------------------------------------- + // lookupSubjects — caveat context (time-based access) + // ------------------------------------------------------------------------- + + @Test + void lookupSubjects_withAtTimestamp_requestIncludesCaveatContext() { + AtomicReference captured = new AtomicReference<>(); + + LookupSpiceDBQuery query = new LookupSpiceDBQuery( + req -> Collections.emptyIterator(), + req -> { + captured.set(req); + return Collections.emptyIterator(); + }); + + java.time.Instant at = java.time.Instant.parse("2026-02-01T00:00:00Z"); + query.lookupSubjects( + new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( + "document", "doc-1", "read_doc", "user", at)); + + com.authzed.api.v1.LookupSubjectsRequest grpcRequest = captured.get(); + assertNotNull(grpcRequest); + assertTrue(grpcRequest.hasContext(), + "gRPC request must include caveat context when at is provided"); + assertEquals(at.toString(), + grpcRequest.getContext().getFieldsOrThrow("at").getStringValue(), + "caveat context 'at' field must contain ISO-8601 timestamp"); + } + + @Test + void lookupSubjects_withNullAt_requestHasNoCaveatContext() { + AtomicReference captured = new AtomicReference<>(); + + LookupSpiceDBQuery query = new LookupSpiceDBQuery( + req -> Collections.emptyIterator(), + req -> { + captured.set(req); + return Collections.emptyIterator(); + }); + + query.lookupSubjects( + new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( + "document", "doc-1", "read_doc", "user")); + + com.authzed.api.v1.LookupSubjectsRequest grpcRequest = captured.get(); + assertNotNull(grpcRequest); + assertFalse(grpcRequest.hasContext(), + "gRPC request must not include caveat context when at is null"); + } + // ------------------------------------------------------------------------- // Base64 roundtrip — encode then decode produces original value // ------------------------------------------------------------------------- diff --git a/src/test/java/com/frontegg/sdk/entitlements/model/LookupModelsTest.java b/src/test/java/com/frontegg/sdk/entitlements/model/LookupModelsTest.java index 6074d12..d61a303 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/model/LookupModelsTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/model/LookupModelsTest.java @@ -3,11 +3,14 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -76,6 +79,41 @@ void recordEquality_sameValues_areEqual() { assertEquals(r1, r2); assertEquals(r1.hashCode(), r2.hashCode()); } + + @Test + void convenienceConstructor_atIsNull() { + LookupResourcesRequest req = new LookupResourcesRequest("u", "1", "p", "r"); + assertNull(req.at(), "convenience constructor must set at to null"); + } + + @Test + void fullConstructor_withAt_fieldsAreSet() { + Instant at = Instant.parse("2026-03-01T00:00:00Z"); + LookupResourcesRequest req = new LookupResourcesRequest("u", "1", "p", "r", at); + + assertEquals(at, req.at()); + assertEquals("u", req.subjectType()); + } + + @Test + void recordEquality_differentAt_areNotEqual() { + Instant at1 = Instant.parse("2026-01-01T00:00:00Z"); + Instant at2 = Instant.parse("2026-02-01T00:00:00Z"); + LookupResourcesRequest r1 = new LookupResourcesRequest("u", "1", "p", "r", at1); + LookupResourcesRequest r2 = new LookupResourcesRequest("u", "1", "p", "r", at2); + + assertNotEquals(r1, r2); + } + + @Test + void recordEquality_sameAt_areEqual() { + Instant at = Instant.parse("2026-01-01T00:00:00Z"); + LookupResourcesRequest r1 = new LookupResourcesRequest("u", "1", "p", "r", at); + LookupResourcesRequest r2 = new LookupResourcesRequest("u", "1", "p", "r", at); + + assertEquals(r1, r2); + assertEquals(r1.hashCode(), r2.hashCode()); + } } // ========================================================================= @@ -128,6 +166,31 @@ void recordEquality_sameValues_areEqual() { assertEquals(r1, r2); assertEquals(r1.hashCode(), r2.hashCode()); } + + @Test + void convenienceConstructor_atIsNull() { + LookupSubjectsRequest req = new LookupSubjectsRequest("d", "1", "v", "u"); + assertNull(req.at(), "convenience constructor must set at to null"); + } + + @Test + void fullConstructor_withAt_fieldsAreSet() { + Instant at = Instant.parse("2026-02-01T00:00:00Z"); + LookupSubjectsRequest req = new LookupSubjectsRequest("d", "1", "v", "u", at); + + assertEquals(at, req.at()); + assertEquals("d", req.resourceType()); + } + + @Test + void recordEquality_differentAt_areNotEqual() { + Instant at1 = Instant.parse("2026-01-01T00:00:00Z"); + Instant at2 = Instant.parse("2026-02-01T00:00:00Z"); + LookupSubjectsRequest r1 = new LookupSubjectsRequest("d", "1", "v", "u", at1); + LookupSubjectsRequest r2 = new LookupSubjectsRequest("d", "1", "v", "u", at2); + + assertNotEquals(r1, r2); + } } // ========================================================================= From 970807bfda55b7d3c90cf56849d800bfa94810f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:25:56 +0000 Subject: [PATCH 02/28] build(deps-dev): Bump org.apache.maven.plugins:maven-source-plugin Bumps [org.apache.maven.plugins:maven-source-plugin](https://github.com/apache/maven-source-plugin) from 3.3.1 to 3.4.0. - [Release notes](https://github.com/apache/maven-source-plugin/releases) - [Commits](https://github.com/apache/maven-source-plugin/compare/maven-source-plugin-3.3.1...maven-source-plugin-3.4.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-source-plugin dependency-version: 3.4.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ccf0e2f..70673fb 100644 --- a/pom.xml +++ b/pom.xml @@ -55,7 +55,7 @@ 3.13.0 3.2.5 - 3.3.1 + 3.4.0 3.7.0 3.2.7 0.10.0 From c5b472b8dfcbb34a5ee006e4667bbf0ad21cf4b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:25:56 +0000 Subject: [PATCH 03/28] build(deps-dev): Bump mockito.version from 5.14.2 to 5.22.0 Bumps `mockito.version` from 5.14.2 to 5.22.0. Updates `org.mockito:mockito-core` from 5.14.2 to 5.22.0 - [Release notes](https://github.com/mockito/mockito/releases) - [Commits](https://github.com/mockito/mockito/compare/v5.14.2...v5.22.0) Updates `org.mockito:mockito-junit-jupiter` from 5.14.2 to 5.22.0 - [Release notes](https://github.com/mockito/mockito/releases) - [Commits](https://github.com/mockito/mockito/compare/v5.14.2...v5.22.0) --- updated-dependencies: - dependency-name: org.mockito:mockito-core dependency-version: 5.22.0 dependency-type: direct:development update-type: version-update:semver-minor - dependency-name: org.mockito:mockito-junit-jupiter dependency-version: 5.22.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 70673fb..fa70189 100644 --- a/pom.xml +++ b/pom.xml @@ -50,7 +50,7 @@ 4.33.5 2.0.16 5.10.3 - 5.14.2 + 5.22.0 3.13.0 From 5126254767866b379aefc38f0d7f71fb7dead986 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:25:54 +0000 Subject: [PATCH 04/28] build(deps): Bump com.google.api.grpc:grpc-google-common-protos Bumps [com.google.api.grpc:grpc-google-common-protos](https://github.com/googleapis/sdk-platform-java) from 2.37.1 to 2.66.0. - [Release notes](https://github.com/googleapis/sdk-platform-java/releases) - [Changelog](https://github.com/googleapis/sdk-platform-java/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/sdk-platform-java/compare/api-common/v2.37.1...v2.66.0) --- updated-dependencies: - dependency-name: com.google.api.grpc:grpc-google-common-protos dependency-version: 2.66.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index fa70189..adb52c1 100644 --- a/pom.xml +++ b/pom.xml @@ -113,7 +113,7 @@ com.google.api.grpc grpc-google-common-protos - 2.37.1 + 2.66.0 From d27abac1690d62ff6b232e9ebfa4a010c494012c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:25:53 +0000 Subject: [PATCH 05/28] build(deps-dev): Bump org.apache.maven.plugins:maven-compiler-plugin Bumps [org.apache.maven.plugins:maven-compiler-plugin](https://github.com/apache/maven-compiler-plugin) from 3.13.0 to 3.15.0. - [Release notes](https://github.com/apache/maven-compiler-plugin/releases) - [Commits](https://github.com/apache/maven-compiler-plugin/compare/maven-compiler-plugin-3.13.0...maven-compiler-plugin-3.15.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-compiler-plugin dependency-version: 3.15.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- entitlements-client-spring-boot-starter/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entitlements-client-spring-boot-starter/pom.xml b/entitlements-client-spring-boot-starter/pom.xml index 62b4cbe..6fa1b25 100644 --- a/entitlements-client-spring-boot-starter/pom.xml +++ b/entitlements-client-spring-boot-starter/pom.xml @@ -81,7 +81,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 + 3.15.0 ${java.version} ${java.version} From d3879b9a3617067593fec503e49579be5e9d4359 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:25:52 +0000 Subject: [PATCH 06/28] build(deps): Bump slf4j.version from 2.0.16 to 2.0.17 Bumps `slf4j.version` from 2.0.16 to 2.0.17. Updates `org.slf4j:slf4j-api` from 2.0.16 to 2.0.17 Updates `org.slf4j:slf4j-simple` from 2.0.16 to 2.0.17 --- updated-dependencies: - dependency-name: org.slf4j:slf4j-api dependency-version: 2.0.17 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.slf4j:slf4j-simple dependency-version: 2.0.17 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index adb52c1..531aa1a 100644 --- a/pom.xml +++ b/pom.xml @@ -48,7 +48,7 @@ 1.5.4 1.78.0 4.33.5 - 2.0.16 + 2.0.17 5.10.3 5.22.0 From 374f0a3da9a94b957939109b9af2e0b142109b18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:25:49 +0000 Subject: [PATCH 07/28] build(deps): Bump com.github.ben-manes.caffeine:caffeine Bumps [com.github.ben-manes.caffeine:caffeine](https://github.com/ben-manes/caffeine) from 3.1.8 to 3.2.3. - [Release notes](https://github.com/ben-manes/caffeine/releases) - [Commits](https://github.com/ben-manes/caffeine/compare/v3.1.8...v3.2.3) --- updated-dependencies: - dependency-name: com.github.ben-manes.caffeine:caffeine dependency-version: 3.2.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 531aa1a..4e72223 100644 --- a/pom.xml +++ b/pom.xml @@ -127,7 +127,7 @@ com.github.ben-manes.caffeine caffeine - 3.1.8 + 3.2.3 true From 550334e69524b7f51fbc932287e7ffd9c308bb0e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:25:49 +0000 Subject: [PATCH 08/28] build(deps-dev): Bump org.apache.maven.plugins:maven-gpg-plugin Bumps [org.apache.maven.plugins:maven-gpg-plugin](https://github.com/apache/maven-gpg-plugin) from 3.2.7 to 3.2.8. - [Release notes](https://github.com/apache/maven-gpg-plugin/releases) - [Commits](https://github.com/apache/maven-gpg-plugin/compare/maven-gpg-plugin-3.2.7...maven-gpg-plugin-3.2.8) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-gpg-plugin dependency-version: 3.2.8 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- entitlements-client-spring-boot-starter/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entitlements-client-spring-boot-starter/pom.xml b/entitlements-client-spring-boot-starter/pom.xml index 6fa1b25..0e1c04f 100644 --- a/entitlements-client-spring-boot-starter/pom.xml +++ b/entitlements-client-spring-boot-starter/pom.xml @@ -124,7 +124,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.2.7 + 3.2.8 sign-artifacts From 4f5015eaec9ed7fcc5358c94f599a5f389556a62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:40:32 +0000 Subject: [PATCH 09/28] build(deps): Bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yaml | 2 +- .github/workflows/publish.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a5fd914..300e02e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: matrix: java: ['17', '21'] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up JDK ${{ matrix.java }} uses: actions/setup-java@v5 with: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 74fb69e..4a37420 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -6,7 +6,7 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up JDK 17 uses: actions/setup-java@v5 with: From da1e27d48b117b5ae9e76cee2c7468be3c3e2c59 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:39:38 +0000 Subject: [PATCH 10/28] build(deps-dev): Bump org.apache.maven.plugins:maven-javadoc-plugin Bumps [org.apache.maven.plugins:maven-javadoc-plugin](https://github.com/apache/maven-javadoc-plugin) from 3.7.0 to 3.12.0. - [Release notes](https://github.com/apache/maven-javadoc-plugin/releases) - [Commits](https://github.com/apache/maven-javadoc-plugin/compare/maven-javadoc-plugin-3.7.0...maven-javadoc-plugin-3.12.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-javadoc-plugin dependency-version: 3.12.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4e72223..d34127f 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ 3.13.0 3.2.5 3.4.0 - 3.7.0 + 3.12.0 3.2.7 0.10.0 From 52124894c59a100213922c2afaea1ced231c46cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:39:31 +0000 Subject: [PATCH 11/28] build(deps-dev): Bump org.apache.maven.plugins:maven-surefire-plugin Bumps [org.apache.maven.plugins:maven-surefire-plugin](https://github.com/apache/maven-surefire) from 3.2.5 to 3.5.5. - [Release notes](https://github.com/apache/maven-surefire/releases) - [Commits](https://github.com/apache/maven-surefire/compare/surefire-3.2.5...surefire-3.5.5) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-surefire-plugin dependency-version: 3.5.5 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d34127f..b99e464 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,7 @@ 3.13.0 - 3.2.5 + 3.5.5 3.4.0 3.12.0 3.2.7 From 06aced623081eaa724f48a83dd1947571c1719f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:25:55 +0000 Subject: [PATCH 12/28] build(deps): Bump spring-boot.version Bumps `spring-boot.version` from 3.2.0 to 4.0.3. Updates `org.springframework.boot:spring-boot-autoconfigure` from 3.2.0 to 4.0.3 - [Release notes](https://github.com/spring-projects/spring-boot/releases) - [Commits](https://github.com/spring-projects/spring-boot/compare/v3.2.0...v4.0.3) Updates `org.springframework.boot:spring-boot-configuration-processor` from 3.2.0 to 4.0.3 - [Release notes](https://github.com/spring-projects/spring-boot/releases) - [Commits](https://github.com/spring-projects/spring-boot/compare/v3.2.0...v4.0.3) Updates `org.springframework.boot:spring-boot-starter-test` from 3.2.0 to 4.0.3 - [Release notes](https://github.com/spring-projects/spring-boot/releases) - [Commits](https://github.com/spring-projects/spring-boot/compare/v3.2.0...v4.0.3) --- updated-dependencies: - dependency-name: org.springframework.boot:spring-boot-autoconfigure dependency-version: 4.0.3 dependency-type: direct:production update-type: version-update:semver-major - dependency-name: org.springframework.boot:spring-boot-configuration-processor dependency-version: 4.0.3 dependency-type: direct:production update-type: version-update:semver-major - dependency-name: org.springframework.boot:spring-boot-starter-test dependency-version: 4.0.3 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- entitlements-client-spring-boot-starter/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entitlements-client-spring-boot-starter/pom.xml b/entitlements-client-spring-boot-starter/pom.xml index 0e1c04f..2b79a5b 100644 --- a/entitlements-client-spring-boot-starter/pom.xml +++ b/entitlements-client-spring-boot-starter/pom.xml @@ -42,7 +42,7 @@ ${java.version} ${java.version} UTF-8 - 3.2.0 + 4.0.3 0.1.0-beta.1 From e6934ddacb8d4a548e14e44284464def17fc1152 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 15:06:00 +0200 Subject: [PATCH 13/28] test: treat CONDITIONAL_PERMISSION as allowed and improve SpiceDB healthcheck reliability - Treat `PERMISSIONSHIP_CONDITIONAL_PERMISSION` as allowed in all query types - Add conditional permission tests for FGA, Permission, Route, and Feature queries - Upgrade actions/checkout from v5 to v6 in CI - Replace `pgrep` with `grpc_health_probe` for more reliable SpiceDB readiness checks - Update SpiceDB healthcheck configuration with longer start period and more retries - Remove deprecated `version` field from docker-compose.yml - Upgrade maven-failsafe-plugin from 3.2.5 to 3.5.5 --- .github/workflows/ci.yaml | 4 +-- e2e/docker-compose.yml | 8 +++--- e2e/run-e2e.sh | 2 +- pom.xml | 2 +- .../internal/FeatureSpiceDBQueryTest.java | 26 +++++++++++++++++++ .../internal/FgaSpiceDBQueryTest.java | 13 ++++++++++ .../internal/PermissionSpiceDBQueryTest.java | 26 +++++++++++++++++++ .../internal/RouteSpiceDBQueryTest.java | 26 +++++++++++++++++++ 8 files changed, 98 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 300e02e..ae763e7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,7 +28,7 @@ jobs: matrix: java: ['17'] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up JDK ${{ matrix.java }} uses: actions/setup-java@v5 with: @@ -40,7 +40,7 @@ jobs: - name: Wait for SpiceDB readiness run: | for i in $(seq 1 30); do - if docker compose -f e2e/docker-compose.yml exec -T spicedb pgrep -f spicedb > /dev/null 2>&1; then + if docker compose -f e2e/docker-compose.yml exec -T spicedb grpc_health_probe -addr=:50051 > /dev/null 2>&1; then echo "SpiceDB is ready" break fi diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 070db3a..327dede 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: spicedb-cockroachdb: image: cockroachdb/cockroach:latest @@ -66,11 +64,11 @@ services: environment: - SPICEDB_GRPC_PRESHARED_KEY=${SPICEDB_GRPC_PRESHARED_KEY:-spicedb} healthcheck: - test: ['CMD', 'pgrep', '-f', 'spicedb'] + test: ['CMD', 'grpc_health_probe', '-addr=:50051'] interval: 10s timeout: 5s - retries: 3 - start_period: 10s + retries: 5 + start_period: 15s depends_on: spicedb-migrate: condition: service_completed_successfully diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh index cbd2ed0..87004cd 100755 --- a/e2e/run-e2e.sh +++ b/e2e/run-e2e.sh @@ -16,7 +16,7 @@ docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --wait echo "Waiting for SpiceDB to be ready..." for i in $(seq 1 30); do - if docker compose -f "$SCRIPT_DIR/docker-compose.yml" exec -T spicedb pgrep -f spicedb > /dev/null 2>&1; then + if docker compose -f "$SCRIPT_DIR/docker-compose.yml" exec -T spicedb grpc_health_probe -addr=:50051 > /dev/null 2>&1; then echo "SpiceDB is ready." break fi diff --git a/pom.xml b/pom.xml index b99e464..7ff2a01 100644 --- a/pom.xml +++ b/pom.xml @@ -303,7 +303,7 @@ org.apache.maven.plugins maven-failsafe-plugin - 3.2.5 + 3.5.5 **/e2e/**Test.java diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java index 24134ca..c8796d4 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java @@ -64,6 +64,32 @@ void query_tenantEntitledUserDenied_returnsAllowed() { assertTrue(result.result(), "tenant entitled → result must be true even if user denied"); } + @Test + void query_userConditionalPermission_returnsAllowed() { + FeatureSpiceDBQuery query = queryWith( + permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION), + permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); + + EntitlementsResult result = query.query( + new UserSubjectContext("user-1", "tenant-1"), + new FeatureRequestContext("feature-key")); + + assertTrue(result.result(), "user conditional permission → result must be true"); + } + + @Test + void query_tenantConditionalPermission_returnsAllowed() { + FeatureSpiceDBQuery query = queryWith( + permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), + permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION)); + + EntitlementsResult result = query.query( + new UserSubjectContext("user-1", "tenant-1"), + new FeatureRequestContext("feature-key")); + + assertTrue(result.result(), "tenant conditional permission → result must be true"); + } + @Test void query_bothDenied_returnsDenied() { FeatureSpiceDBQuery query = queryWith( diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java index 2f007d5..1aad4df 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java @@ -56,6 +56,19 @@ void query_permissionDenied_returnsDenied() { assertFalse(result.monitoring(), "monitoring must be false for normal check"); } + @Test + void query_conditionalPermission_returnsAllowed() { + FgaSpiceDBQuery query = queryWith( + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); + + EntitlementsResult result = query.query( + new EntitySubjectContext("service_account", "svc-deployer-01"), + new EntityRequestContext("document", "doc-789", "viewer")); + + assertTrue(result.result(), "PERMISSIONSHIP_CONDITIONAL_PERMISSION → result must be true"); + assertFalse(result.monitoring(), "monitoring must be false for normal check"); + } + @Test void query_unspecifiedPermissionship_returnsDenied() { FgaSpiceDBQuery query = queryWith( diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQueryTest.java index 08006d0..5e0e63e 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQueryTest.java @@ -66,6 +66,32 @@ void query_singlePermission_tenantEntitledUserDenied_returnsAllowed() { assertTrue(result.result(), "tenant entitled → result must be true even if user denied"); } + @Test + void query_singlePermission_userConditionalPermission_returnsAllowed() { + PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> + responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION, + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); + + EntitlementsResult result = query.query( + new UserSubjectContext("user-1", "tenant-1"), + new PermissionRequestContext("reports:read")); + + assertTrue(result.result(), "user conditional permission → result must be true"); + } + + @Test + void query_singlePermission_tenantConditionalPermission_returnsAllowed() { + PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> + responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION, + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION)); + + EntitlementsResult result = query.query( + new UserSubjectContext("user-1", "tenant-1"), + new PermissionRequestContext("reports:read")); + + assertTrue(result.result(), "tenant conditional permission → result must be true"); + } + @Test void query_singlePermission_bothDenied_returnsDenied() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQueryTest.java index d78a5de..2a663a4 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQueryTest.java @@ -69,6 +69,32 @@ void query_tenantEntitledUserDenied_returnsAllowed() { assertTrue(result.result(), "tenant entitled → result must be true even if user denied"); } + @Test + void query_userConditionalPermission_returnsAllowed() { + RouteSpiceDBQuery query = queryWith( + permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION), + permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); + + EntitlementsResult result = query.query( + new UserSubjectContext("user-1", "tenant-1"), + new RouteRequestContext("GET", "/api/v1/reports")); + + assertTrue(result.result(), "user conditional permission → result must be true"); + } + + @Test + void query_tenantConditionalPermission_returnsAllowed() { + RouteSpiceDBQuery query = queryWith( + permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), + permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION)); + + EntitlementsResult result = query.query( + new UserSubjectContext("user-1", "tenant-1"), + new RouteRequestContext("POST", "/api/v1/data")); + + assertTrue(result.result(), "tenant conditional permission → result must be true"); + } + @Test void query_bothDenied_returnsDenied() { RouteSpiceDBQuery query = queryWith( From bc190adacc0efe0904add86acff1b33ed901ef0a Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 15:20:27 +0200 Subject: [PATCH 14/28] fix(e2e): add restart policy to SpiceDB container for CI reliability SpiceDB exits with code 78 (failed to create datastore) when CockroachDB isn't fully ready for connection pooling despite passing healthcheck. Adding restart: on-failure lets it retry. Also removes cpus/mem_limit constraints that can cause issues on GitHub Actions runners. Co-Authored-By: Claude Opus 4.6 --- e2e/docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 327dede..521638a 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -48,8 +48,7 @@ services: spicedb: image: authzed/spicedb:v1.42.1 - cpus: 0.5 - mem_limit: 512m + restart: on-failure ports: - '50051:50051' volumes: From 0e4e0f0d744bd7e3a18c547bdcec10a82dda2f25 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 15:24:07 +0200 Subject: [PATCH 15/28] fix(e2e): drop --wait flag and rely on readiness loop for SpiceDB startup docker compose --wait fails when SpiceDB restarts after an initial connection failure to CockroachDB. Instead, start detached and let the grpc_health_probe readiness loop (up to 120s) handle waiting. Also dumps SpiceDB logs on timeout for easier debugging. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 10 ++++++---- e2e/run-e2e.sh | 7 ++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ae763e7..ab61554 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,16 +36,18 @@ jobs: distribution: temurin cache: maven - name: Start SpiceDB - run: docker compose -f e2e/docker-compose.yml up -d --wait + run: docker compose -f e2e/docker-compose.yml up -d - name: Wait for SpiceDB readiness run: | - for i in $(seq 1 30); do + for i in $(seq 1 60); do if docker compose -f e2e/docker-compose.yml exec -T spicedb grpc_health_probe -addr=:50051 > /dev/null 2>&1; then echo "SpiceDB is ready" break fi - if [ "$i" -eq 30 ]; then - echo "SpiceDB did not become ready" && exit 1 + if [ "$i" -eq 60 ]; then + echo "SpiceDB did not become ready" + docker compose -f e2e/docker-compose.yml logs spicedb + exit 1 fi sleep 2 done diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh index 87004cd..b552a35 100755 --- a/e2e/run-e2e.sh +++ b/e2e/run-e2e.sh @@ -12,16 +12,17 @@ cleanup() { trap cleanup EXIT echo "Starting SpiceDB infrastructure..." -docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --wait +docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d echo "Waiting for SpiceDB to be ready..." -for i in $(seq 1 30); do +for i in $(seq 1 60); do if docker compose -f "$SCRIPT_DIR/docker-compose.yml" exec -T spicedb grpc_health_probe -addr=:50051 > /dev/null 2>&1; then echo "SpiceDB is ready." break fi - if [ "$i" -eq 30 ]; then + if [ "$i" -eq 60 ]; then echo "ERROR: SpiceDB did not become ready in time." + docker compose -f "$SCRIPT_DIR/docker-compose.yml" logs spicedb exit 1 fi sleep 2 From 690446e6f580194321037831912b6f3f15b22487 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 15:34:49 +0200 Subject: [PATCH 16/28] fix(e2e): pin CockroachDB to v24.2.6 for SpiceDB v1.42.1 compatibility CockroachDB v26.1 restricts access to crdb_internal tables by default. SpiceDB v1.42.1 queries crdb_internal.active_version() on startup, causing persistent "Access to crdb_internal and system is restricted" errors. Pin to v24.2.6 which does not have this restriction. Co-Authored-By: Claude Opus 4.6 --- e2e/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 521638a..9ff1465 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,6 +1,6 @@ services: spicedb-cockroachdb: - image: cockroachdb/cockroach:latest + image: cockroachdb/cockroach:v24.2.6 ports: - '26257:26257' volumes: @@ -18,7 +18,7 @@ services: - PGDATABASE=spicedb spicedb-create-db: - image: cockroachdb/cockroach:latest + image: cockroachdb/cockroach:v24.2.6 command: [ 'sql', From b46e3357ca80ed5b1d3ee5324a08c1d5b8efd86b Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 15:40:37 +0200 Subject: [PATCH 17/28] debug(e2e): add setup logging to diagnose schema write failures in CI Co-Authored-By: Claude Opus 4.6 --- .../com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java index 5aca849..6807839 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java @@ -43,11 +43,17 @@ static void setup() { String endpoint = System.getProperty("spicedb.endpoint", "localhost:50051"); String token = System.getProperty("spicedb.token", "spicedb"); + System.out.println("[E2E] SpiceDB endpoint=" + endpoint + " token=" + token); + // Seed schema and relationships via gRPC (idempotent) schemaWriter = new SpiceDBSchemaWriter(endpoint, token); + System.out.println("[E2E] Writing schema..."); schemaWriter.writeSchema(); + System.out.println("[E2E] Schema written. Writing relationships..."); schemaWriter.writeRelationships(); + System.out.println("[E2E] Relationships written. Writing caveat relationships..."); schemaWriter.writeCaveatRelationships(); + System.out.println("[E2E] All seed data written."); ClientConfiguration config = ClientConfiguration.builder() .engineEndpoint(endpoint) @@ -56,6 +62,7 @@ static void setup() { .build(); client = EntitlementsClientFactory.create(config); + System.out.println("[E2E] Client created. Setup complete."); } @AfterAll From 409efc153683a6894b19f4ff637d0d85e180711b Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 15:43:27 +0200 Subject: [PATCH 18/28] debug(e2e): enable console output for failsafe to see setup logs Co-Authored-By: Claude Opus 4.6 --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index 7ff2a01..cc29c6d 100644 --- a/pom.xml +++ b/pom.xml @@ -308,6 +308,7 @@ **/e2e/**Test.java + false ${spicedb.endpoint} ${spicedb.token} From 1f66415b026ff218216c95e41d660750ffe1726a Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 15:46:34 +0200 Subject: [PATCH 19/28] fix(e2e): disable revision quantization to prevent stale schema reads SpiceDB with CockroachDB uses quantized revisions by default, which can cause queries immediately after schema writes to use a revision that predates the schema. Setting quantization to 0s ensures queries always see the latest schema. Co-Authored-By: Claude Opus 4.6 --- e2e/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 9ff1465..53c19e2 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -58,6 +58,7 @@ services: 'serve', '--datastore-engine=cockroachdb', '--datastore-conn-uri=postgres://root:password@spicedb-cockroachdb:26257/spicedb?sslmode=disable', + '--datastore-revision-quantization-interval=0s', '--log-level=info' ] environment: From a05a1fc1b18dad58dcc003fbdfe662268441ab3f Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 15:50:59 +0200 Subject: [PATCH 20/28] fix(e2e): switch SpiceDB to in-memory datastore for E2E tests CockroachDB-backed SpiceDB has revision quantization issues causing stale schema reads in CI. The E2E tests don't need persistent storage - they seed data in @BeforeAll. Using the memory engine eliminates CockroachDB complexity, speeds up startup, and avoids consistency issues. Co-Authored-By: Claude Opus 4.6 --- e2e/docker-compose.yml | 69 +++--------------------------------------- 1 file changed, 4 insertions(+), 65 deletions(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 53c19e2..0a15cfe 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,80 +1,19 @@ services: - spicedb-cockroachdb: - image: cockroachdb/cockroach:v24.2.6 - ports: - - '26257:26257' - volumes: - - spicedb-cockroachdb-data:/cockroach/cockroach-data - command: ['start-single-node', '--insecure'] - healthcheck: - test: ['CMD', '/cockroach/cockroach', 'sql', '--insecure', '--execute', 'SELECT 1'] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s - environment: - - PGUSER=root - - PGPASSWORD=password - - PGDATABASE=spicedb - - spicedb-create-db: - image: cockroachdb/cockroach:v24.2.6 - command: - [ - 'sql', - '--insecure', - '--host=spicedb-cockroachdb:26257', - '--execute=CREATE DATABASE IF NOT EXISTS spicedb;' - ] - depends_on: - spicedb-cockroachdb: - condition: service_healthy - restart: 'no' - - spicedb-migrate: - image: authzed/spicedb:v1.42.1 - command: - [ - 'datastore', - 'migrate', - 'head', - '--datastore-engine=cockroachdb', - '--datastore-conn-uri=postgres://root:password@spicedb-cockroachdb:26257/spicedb?sslmode=disable' - ] - depends_on: - spicedb-create-db: - condition: service_completed_successfully - restart: 'no' - spicedb: image: authzed/spicedb:v1.42.1 - restart: on-failure ports: - '50051:50051' - volumes: - - spicedb-data:/data command: [ 'serve', - '--datastore-engine=cockroachdb', - '--datastore-conn-uri=postgres://root:password@spicedb-cockroachdb:26257/spicedb?sslmode=disable', - '--datastore-revision-quantization-interval=0s', + '--datastore-engine=memory', '--log-level=info' ] environment: - SPICEDB_GRPC_PRESHARED_KEY=${SPICEDB_GRPC_PRESHARED_KEY:-spicedb} healthcheck: test: ['CMD', 'grpc_health_probe', '-addr=:50051'] - interval: 10s - timeout: 5s + interval: 5s + timeout: 3s retries: 5 - start_period: 15s - depends_on: - spicedb-migrate: - condition: service_completed_successfully - spicedb-cockroachdb: - condition: service_healthy - -volumes: - spicedb-data: - spicedb-cockroachdb-data: + start_period: 5s \ No newline at end of file From ce4471eec8451b5452eafa0fb82cb33cee174b7d Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 16:33:22 +0200 Subject: [PATCH 21/28] fix(e2e): remove debug logging and clean up test configuration Remove temporary debug traces from FgaSpiceDBQuery and SpiceDBE2ETest that were used to diagnose CI failures. Remove redirectTestOutputToFile from failsafe config. Co-Authored-By: Claude Opus 4.6 --- pom.xml | 1 - .../com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java | 7 ------- 2 files changed, 8 deletions(-) diff --git a/pom.xml b/pom.xml index cc29c6d..7ff2a01 100644 --- a/pom.xml +++ b/pom.xml @@ -308,7 +308,6 @@ **/e2e/**Test.java - false ${spicedb.endpoint} ${spicedb.token} diff --git a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java index 6807839..5aca849 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java @@ -43,17 +43,11 @@ static void setup() { String endpoint = System.getProperty("spicedb.endpoint", "localhost:50051"); String token = System.getProperty("spicedb.token", "spicedb"); - System.out.println("[E2E] SpiceDB endpoint=" + endpoint + " token=" + token); - // Seed schema and relationships via gRPC (idempotent) schemaWriter = new SpiceDBSchemaWriter(endpoint, token); - System.out.println("[E2E] Writing schema..."); schemaWriter.writeSchema(); - System.out.println("[E2E] Schema written. Writing relationships..."); schemaWriter.writeRelationships(); - System.out.println("[E2E] Relationships written. Writing caveat relationships..."); schemaWriter.writeCaveatRelationships(); - System.out.println("[E2E] All seed data written."); ClientConfiguration config = ClientConfiguration.builder() .engineEndpoint(endpoint) @@ -62,7 +56,6 @@ static void setup() { .build(); client = EntitlementsClientFactory.create(config); - System.out.println("[E2E] Client created. Setup complete."); } @AfterAll From 8df21e61753e5466f05f5017296808c8a61a15c2 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 16:53:21 +0200 Subject: [PATCH 22/28] debug(e2e): add diagnostic logging to identify CI test failures Add schema read-back verification and relationship dumping to @BeforeAll setup to diagnose why E2E tests fail in CI but pass locally. Co-Authored-By: Claude Opus 4.6 --- .../sdk/entitlements/e2e/SpiceDBE2ETest.java | 55 ++++++++++++++++++- .../integration/SpiceDBSchemaWriter.java | 35 ++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java index 5aca849..647189d 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java @@ -45,9 +45,44 @@ static void setup() { // Seed schema and relationships via gRPC (idempotent) schemaWriter = new SpiceDBSchemaWriter(endpoint, token); - schemaWriter.writeSchema(); - schemaWriter.writeRelationships(); - schemaWriter.writeCaveatRelationships(); + try { + schemaWriter.writeSchema(); + System.out.println("=== DEBUG: Schema written OK ==="); + } catch (Exception e) { + System.out.println("=== DEBUG: Schema write FAILED: " + e.getMessage() + " ==="); + throw e; + } + try { + schemaWriter.writeRelationships(); + System.out.println("=== DEBUG: Relationships written OK ==="); + } catch (Exception e) { + System.out.println("=== DEBUG: Relationships write FAILED: " + e.getMessage() + " ==="); + throw e; + } + try { + schemaWriter.writeCaveatRelationships(); + System.out.println("=== DEBUG: Caveat relationships written OK ==="); + } catch (Exception e) { + System.out.println("=== DEBUG: Caveat relationships write FAILED: " + e.getMessage() + " ==="); + throw e; + } + + // Verify schema was actually written + String schema = schemaWriter.readSchema(); + System.out.println("=== DEBUG: Schema contains 'document': " + schema.contains("document") + " ==="); + System.out.println("=== DEBUG: Schema contains 'active_at': " + schema.contains("active_at") + " ==="); + + // Verify relationships were written + var docRels = schemaWriter.readRelationships("document"); + System.out.println("=== DEBUG: Document relationships count: " + docRels.size() + " ==="); + for (String rel : docRels) { + System.out.println("=== DEBUG: Rel: " + rel + " ==="); + } + var folderRels = schemaWriter.readRelationships("folder"); + System.out.println("=== DEBUG: Folder relationships count: " + folderRels.size() + " ==="); + for (String rel : folderRels) { + System.out.println("=== DEBUG: Rel: " + rel + " ==="); + } ClientConfiguration config = ClientConfiguration.builder() .engineEndpoint(endpoint) @@ -56,6 +91,20 @@ static void setup() { .build(); client = EntitlementsClientFactory.create(config); + + // Debug: test a simple check right after setup + System.out.println("=== DEBUG: Testing Tim read_doc Tim's_salary_Jan at 2026-01-01 ==="); + EntitlementsResult debugResult = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", + Instant.parse("2026-01-01T00:00:00Z"))); + System.out.println("=== DEBUG: result=" + debugResult.result() + " ==="); + + // Also test WITHOUT caveat context to see if relationship exists at all + EntitlementsResult debugResult2 = client.isEntitledTo( + new EntitySubjectContext("frontegg_user", "Tim"), + new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", null)); + System.out.println("=== DEBUG: result without at=" + debugResult2.result() + " ==="); } @AfterAll diff --git a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java index e19dd27..36dcf96 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java +++ b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java @@ -247,6 +247,41 @@ static String encode(String value) { .encodeToString(value.getBytes(StandardCharsets.UTF_8)); } + /** + * Reads back the current schema from SpiceDB (for diagnostics). + * + * @return the schema text currently stored in SpiceDB + */ + public String readSchema() { + var response = schemaStub.readSchema( + com.authzed.api.v1.ReadSchemaRequest.newBuilder().build()); + return response.getSchemaText(); + } + + /** + * Reads back all relationships for a given resource type (for diagnostics). + * + * @return list of relationship strings + */ + public java.util.List readRelationships(String resourceType) { + var response = permissionsStub.readRelationships( + com.authzed.api.v1.ReadRelationshipsRequest.newBuilder() + .setRelationshipFilter(com.authzed.api.v1.RelationshipFilter.newBuilder() + .setResourceType(resourceType) + .build()) + .build()); + java.util.List results = new java.util.ArrayList<>(); + while (response.hasNext()) { + var r = response.next().getRelationship(); + results.add(r.getResource().getObjectType() + ":" + r.getResource().getObjectId() + + "#" + r.getRelation() + + "@" + r.getSubject().getObject().getObjectType() + ":" + r.getSubject().getObject().getObjectId() + + (r.hasOptionalCaveat() && !r.getOptionalCaveat().getCaveatName().isEmpty() + ? "[" + r.getOptionalCaveat().getCaveatName() + "]" : "")); + } + return results; + } + /** * Shuts down the underlying gRPC channel. Call in {@code @AfterAll}. */ From 6e34a04facf960d32755e1bcdf15dc44d534612e Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 17:00:41 +0200 Subject: [PATCH 23/28] debug(e2e): add detailed gRPC response logging to diagnose CI failures Log the raw permissionship from CheckPermission responses and LookupResources results to understand why SpiceDB returns denied when relationships exist. Co-Authored-By: Claude Opus 4.6 --- docs/brownfield-architecture.md | 507 ++++++++++++++++++ .../internal/FgaSpiceDBQuery.java | 6 + .../internal/LookupSpiceDBQuery.java | 4 +- .../integration/SpiceDBSchemaWriter.java | 12 +- 4 files changed, 525 insertions(+), 4 deletions(-) create mode 100644 docs/brownfield-architecture.md diff --git a/docs/brownfield-architecture.md b/docs/brownfield-architecture.md new file mode 100644 index 0000000..f393de3 --- /dev/null +++ b/docs/brownfield-architecture.md @@ -0,0 +1,507 @@ +# Entitlements Client Java SDK — Brownfield Architecture Document + +## Introduction + +This document captures the **current state** of the `entitlements-client-java` codebase as of v0.1.0-beta.1, including actual patterns, implementation details, technical constraints, and integration points. It serves as a reference for AI agents and developers working on enhancements to the SDK. + +This is a **client library** (SDK) — not a service. It wraps Frontegg's SpiceDB-backed ReBAC authorization engine via gRPC, enabling JVM applications to perform real-time permission checks. + +### Document Scope + +Comprehensive documentation of entire system. + +### Change Log + +| Date | Version | Description | Author | +|------------|---------|---------------------------------|--------------| +| 2026-03-11 | 1.0 | Initial brownfield analysis | AI-assisted | + +--- + +## Quick Reference — Key Files and Entry Points + +### Critical Files for Understanding the System + +- **Public API Interface**: `src/main/java/com/frontegg/sdk/entitlements/EntitlementsClient.java` +- **Client Factory**: `src/main/java/com/frontegg/sdk/entitlements/EntitlementsClientFactory.java` +- **Configuration**: `src/main/java/com/frontegg/sdk/entitlements/config/ClientConfiguration.java` +- **Query Dispatcher**: `src/main/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClient.java` +- **Strategy Implementations**: `src/main/java/com/frontegg/sdk/entitlements/internal/Feature|Permission|Route|FgaSpiceDBQuery.java` +- **Retry Logic**: `src/main/java/com/frontegg/sdk/entitlements/internal/RetryHandler.java` +- **Cache Layer**: `src/main/java/com/frontegg/sdk/entitlements/cache/CaffeineCacheProvider.java` +- **Fallback Strategies**: `src/main/java/com/frontegg/sdk/entitlements/fallback/FallbackStrategy.java` +- **Spring Boot Auto-Config**: `entitlements-client-spring-boot-starter/src/main/java/.../EntitlementsAutoConfiguration.java` +- **Test Mock**: `entitlements-client-test/src/main/java/.../MockEntitlementsClient.java` + +### Build & CI + +- **Root POM**: `pom.xml` +- **CI Pipeline**: `.github/workflows/ci.yaml` (matrix: Java 17, 21) +- **Publish Pipeline**: `.github/workflows/publish.yaml` (tag-triggered, Maven Central) +- **Release Config**: `.releaserc.yaml` (semantic-release, conventional commits) + +--- + +## High Level Architecture + +### Technical Summary + +| Category | Technology | Version | Notes | +|------------------|-------------------|---------|------------------------------------------| +| Language | Java | 17+ | Sealed interfaces, records used heavily | +| Build | Maven | 3.x | Multi-module project | +| gRPC Transport | grpc-netty-shaded | 1.78.0 | Shaded Netty for conflict avoidance | +| gRPC Client | grpc-stub | 1.78.0 | gRPC stubs for SpiceDB API | +| SpiceDB Client | authzed-java | 1.5.4 | Authzed's official Java client | +| Protobuf | protobuf-java | 4.33.5 | Protocol Buffers runtime | +| Caching | Caffeine | 3.1.8 | Optional dependency, LRU + TTL | +| Logging | SLF4J | 2.0.16 | Facade only — no runtime binding shipped | +| Spring Boot | autoconfigure | 3.2.0 | Provided scope in starter module | +| Testing | JUnit Jupiter | 5.10.3 | Unit tests | +| Testing | Mockito | 5.14.2 | Mocking framework | +| Testing | Testcontainers | 1.19.8 | Docker-based SpiceDB integration tests | + +### Repository Structure + +Type: **Multi-module Maven project** + +```text +entitlements-client-java/ +├── src/ # Core SDK (44 Java source files) +│ ├── main/java/com/frontegg/sdk/entitlements/ +│ │ ├── EntitlementsClient.java # Public API interface (sealed) +│ │ ├── EntitlementsClientFactory.java # Static factory, validates config +│ │ ├── config/ +│ │ │ ├── ClientConfiguration.java # Builder pattern, immutable +│ │ │ └── CacheConfiguration.java # Cache settings (maxSize, TTL) +│ │ ├── model/ +│ │ │ ├── SubjectContext.java # Sealed: User | Entity subject +│ │ │ ├── RequestContext.java # Sealed: Feature | Permission | Route | Entity +│ │ │ ├── EntitlementsResult.java # Record: result + monitoring flag +│ │ │ ├── LookupResult.java # Record: list of entity IDs +│ │ │ ├── LookupResourcesRequest.java # Lookup resources request +│ │ │ └── LookupSubjectsRequest.java # Lookup subjects request +│ │ ├── exception/ +│ │ │ ├── EntitlementsException.java # Base exception +│ │ │ ├── EntitlementsQueryException.java # SpiceDB gRPC errors +│ │ │ ├── EntitlementsTimeoutException.java # DEADLINE_EXCEEDED +│ │ │ ├── ConfigurationMissingException.java # Required field absent +│ │ │ └── ConfigurationInvalidException.java # Invalid field value +│ │ ├── fallback/ +│ │ │ ├── FallbackStrategy.java # Sealed interface +│ │ │ ├── StaticFallback.java # Fixed true/false result +│ │ │ ├── FunctionFallback.java # Custom function handler +│ │ │ └── FallbackContext.java # Context passed to handler +│ │ ├── cache/ +│ │ │ ├── CacheProvider.java # Generic K,V cache interface +│ │ │ └── CaffeineCacheProvider.java # Caffeine-backed implementation +│ │ └── internal/ # Package-private implementation +│ │ ├── SpiceDBEntitlementsClient.java # Core client implementation +│ │ ├── SpiceDBQueryClient.java # Strategy dispatcher +│ │ ├── InternalClientFactory.java # gRPC channel factory +│ │ ├── FeatureSpiceDBQuery.java # Feature check (OR semantics) +│ │ ├── PermissionSpiceDBQuery.java # Permission check (AND semantics) +│ │ ├── RouteSpiceDBQuery.java # Route check (regex matching) +│ │ ├── FgaSpiceDBQuery.java # FGA check (single relation) +│ │ ├── LookupSpiceDBQuery.java # Lookup operations (streaming) +│ │ ├── RetryHandler.java # Exponential backoff retry +│ │ ├── BearerTokenCallCredentials.java # gRPC auth credentials +│ │ ├── CaveatContextBuilder.java # Attribute map → Protobuf Struct +│ │ ├── Base64Utils.java # URL-safe Base64 (no padding) +│ │ └── EntitlementsCacheKey.java # Cache key record +│ └── test/java/com/frontegg/sdk/entitlements/ # 20 test files +│ ├── config/ # Configuration tests +│ ├── internal/ # Strategy & client tests +│ ├── cache/ # Cache behavior tests +│ └── integration/ # SpiceDB Testcontainers tests +├── entitlements-client-bom/ # Bill of Materials (version alignment) +│ └── pom.xml +├── entitlements-client-spring-boot-starter/ # Spring Boot auto-configuration +│ └── src/main/java/.../ +│ ├── EntitlementsAutoConfiguration.java +│ └── EntitlementsProperties.java +├── entitlements-client-test/ # Test utilities for consumers +│ └── src/main/java/.../ +│ ├── MockEntitlementsClient.java # Configurable mock +│ └── RecordingEntitlementsClient.java # Spy wrapper +├── .github/workflows/ +│ ├── ci.yaml # Build + test matrix +│ └── publish.yaml # Maven Central publish +├── pom.xml # Root POM +├── .releaserc.yaml # Semantic-release config +└── docs/ # Documentation +``` + +### Published Maven Artifacts (4) + +| Artifact | Type | Consumers | Contains | +|---------------------------------------------|------|--------------------|---------------------------------------------------- +| `com.frontegg.sdk:entitlements-client` | jar | All JVM apps | Core gRPC client, config, models, cache, fallback | +| `com.frontegg.sdk:entitlements-client-bom` | pom | Multi-SDK projects | Version alignment for all artifacts + dependencies | +| `com.frontegg.sdk:entitlements-client-spring-boot-starter` | jar | Spring Boot apps | Auto-configuration + properties binding | +| `com.frontegg.sdk:entitlements-client-test` | jar | Test suites | MockEntitlementsClient, RecordingEntitlementsClient | + +--- + +## Source Tree and Module Organization + +### Key Modules and Their Purpose + +#### Core SDK (`src/main/java`) +The heart of the library. 44 source files organized into public API surface (`EntitlementsClient`, `EntitlementsClientFactory`, `config.*`, `model.*`, `exception.*`, `fallback.*`, `cache.*`) and package-private implementation (`internal.*`). + +**Design principle**: All `internal` classes are package-private. Consumers interact only through: +- `EntitlementsClient` interface (sealed) +- `EntitlementsClientFactory.create(config)` factory method +- Immutable records and sealed interfaces for models + +#### Spring Boot Starter (`entitlements-client-spring-boot-starter/`) +Auto-configuration that creates an `EntitlementsClient` bean from `application.yml` properties. Activated by default when on classpath; disabled via `frontegg.entitlements.enabled=false`. + +#### Test Utilities (`entitlements-client-test/`) +Two test doubles for consumers: +- `MockEntitlementsClient` — configurable mock (default: all denied), records calls +- `RecordingEntitlementsClient` — spy that wraps a real client and records interactions + +#### BOM (`entitlements-client-bom/`) +Manages versions of all four Frontegg artifacts plus transitive dependencies (gRPC, Protobuf, authzed-java, SLF4J, Caffeine). + +--- + +## Architecture Patterns — Actual Implementation + +### 1. Strategy Pattern — Query Dispatch + +`SpiceDBQueryClient` routes requests based on `RequestContext` type via sealed interface pattern matching: + +| RequestContext Type | Strategy Class | SpiceDB RPC Used | Semantics | +|--------------------------|--------------------------|--------------------------|------------------| +| `FeatureRequestContext` | `FeatureSpiceDBQuery` | `checkBulkPermissions` | OR (user OR tenant) | +| `PermissionRequestContext` | `PermissionSpiceDBQuery` | `checkBulkPermissions` | AND (all permissions) | +| `RouteRequestContext` | `RouteSpiceDBQuery` | `checkBulkPermissions` | Regex route match | +| `EntityRequestContext` | `FgaSpiceDBQuery` | `checkPermission` | Single relation | + +**Key detail**: Feature checks send 2 items (user + tenant) and return true if **either** is entitled. Permission checks send 2N items (N permissions x 2 subjects) and return true only if **all** are entitled. + +### 2. gRPC Channel Management + +`InternalClientFactory` creates a single shared `ManagedChannel`: +- Uses `NettyChannelBuilder` (shaded transport) +- Keep-alive: 30s interval, 10s timeout +- Max inbound message: 16MB +- Port defaults: 443 (TLS) / 50051 (plaintext) +- `BearerTokenCallCredentials` reads token from `Supplier` on every call (supports rotation) + +### 3. Retry with Exponential Backoff + +`RetryHandler` retries only transient gRPC errors: +- **Retryable**: `UNAVAILABLE`, `DEADLINE_EXCEEDED` +- **Non-retryable**: All others (fail immediately) +- **Backoff formula**: `min(200ms * 2^attempt, 2000ms) + random(0-100ms)` +- **Max retries**: Configurable (default 3) + +### 4. Caching Layer + +`CaffeineCacheProvider` provides optional in-memory caching: +- **Key**: `EntitlementsCacheKey(subjectContext, requestContext)` — composite record +- **Defaults**: 10,000 max entries, 60s TTL (write-time expiry) +- **Rule**: Only caches successful, non-monitoring results +- **Dependency**: Caffeine is `true` — consumers must add it explicitly + +### 5. Fallback Strategy (Sealed) + +When all retries are exhausted: +- `StaticFallback(boolean)` — always returns fixed result +- `FunctionFallback(Function)` — custom logic with full context (subject, request, cause) +- No fallback configured → exception propagates to caller +- **Monitoring mode bypasses fallback** (always returns allowed) + +### 6. Monitoring Mode + +Non-enforcement observability: +- Executes the real SpiceDB check +- Logs result at INFO level +- **Always returns** `EntitlementsResult(true, monitoring=true)` regardless of actual result +- Fallback is not invoked in monitoring mode + +### 7. Base64 Encoding + +All SpiceDB object IDs are Base64-encoded before sending and decoded on return: +- **RFC 4648 Section 5** — URL-safe alphabet (`-_` instead of `+/`) +- **No padding** — matches the TypeScript SDK's `normalizeObjectId` +- Cross-SDK compatibility requirement + +### 8. Caveat Context Builder + +Converts user attributes `Map` + optional `Instant` into Protobuf `Struct`: +- Supports: String, Number, Boolean, null +- Unsupported types silently skipped +- Time parameter formatted as ISO-8601 string under key `"at"` +- Returns `null` (not empty Struct) when nothing to encode — SpiceDB treats them differently + +--- + +## Data Models and APIs + +### Subject Context Hierarchy (Sealed) + +```java +SubjectContext (sealed) +├── UserSubjectContext(String userId, String tenantId, Map attributes) +│ // Encodes as: frontegg_user: + frontegg_tenant: +└── EntitySubjectContext(String entityType, String entityId) + // Encodes as: : +``` + +### Request Context Hierarchy (Sealed) + +```java +RequestContext (sealed) +├── FeatureRequestContext(String featureKey, Instant at) +│ // Checks: frontegg_feature:#entitled +├── PermissionRequestContext(List permissionKeys, Instant at) +│ // Checks: frontegg_permission:#entitled for each key +├── RouteRequestContext(String method, String path, Instant at) +│ // Checks: frontegg_route:#entitled +└── EntityRequestContext(String resourceType, String resourceId, String relation) + // Checks: :# +``` + +### Result Models (Records) + +- `EntitlementsResult(boolean result, boolean monitoring)` — entitlement check result +- `LookupResult(List entityIds)` — immutable list of matched IDs +- `LookupResourcesRequest(String subjectType, String subjectId, String permission, String resourceType)` +- `LookupSubjectsRequest(String resourceType, String resourceId, String permission, String subjectType)` + +### Exception Hierarchy + +```java +EntitlementsException (extends RuntimeException) +├── ConfigurationMissingException // Required config field absent +├── ConfigurationInvalidException // Invalid config field value +└── EntitlementsQueryException // SpiceDB gRPC error (carries Status.Code) + └── EntitlementsTimeoutException // DEADLINE_EXCEEDED specifically +``` + +### SpiceDB gRPC RPCs Used + +| RPC | Used By | Semantics | +|------------------------|----------------------------|-------------------------------------| +| `CheckPermission` | `FgaSpiceDBQuery` | Single entity-to-entity check | +| `CheckBulkPermissions` | `Feature/Permission/Route` | Batch check multiple tuples | +| `LookupResources` | `LookupSpiceDBQuery` | Find all resource IDs (streaming) | +| `LookupSubjects` | `LookupSpiceDBQuery` | Find all subject IDs (streaming) | + +--- + +## Configuration + +### Direct API Usage + +```java +ClientConfiguration config = ClientConfiguration.builder() + .engineEndpoint("grpc.authz.example.com:443") // Required + .engineToken("your-spicedb-token") // Required + .requestTimeout(Duration.ofSeconds(5)) // Default: 5s + .bulkRequestTimeout(Duration.ofSeconds(15)) // Default: 15s + .maxRetries(3) // Default: 3 + .useTls(true) // Default: true + .monitoring(false) // Default: false + .cacheConfiguration(CacheConfiguration.defaults()) // Optional + .fallbackStrategy(new StaticFallback(false)) // Optional + .build(); + +EntitlementsClient client = EntitlementsClientFactory.create(config); +``` + +### Spring Boot Properties + +```yaml +frontegg: + entitlements: + enabled: true # default: true + engine-endpoint: grpc.authz.example.com:443 + engine-token: ${ENTITLEMENTS_ENGINE_TOKEN} + use-tls: true # default: true + request-timeout: 5s # default: 5s + bulk-request-timeout: 15s # default: 15s + max-retries: 3 # default: 3 + monitoring: false # default: false + fallback-result: false # null = no fallback (static only) + cache: + max-size: 10000 # default: 10000 + expire-after-write: 60s # default: 60s +``` + +### Configuration Defaults and Constraints + +| Field | Default | Validation | +|--------------------|---------|---------------------------------------------| +| engineEndpoint | — | Required, non-blank | +| engineToken | — | Required, non-blank | +| requestTimeout | 5s | Must be positive | +| bulkRequestTimeout | 15s | Must be positive | +| maxRetries | 3 | Must be >= 0 | +| useTls | true | — | +| monitoring | false | — | +| cacheConfiguration | null | If provided, maxSize > 0, expiry > 0 | +| fallbackStrategy | null | If provided, must be StaticFallback or FunctionFallback | + +--- + +## Integration Points and External Dependencies + +### External: SpiceDB Authorization Engine + +| Aspect | Detail | +|-------------------|-------------------------------------------------------| +| Protocol | gRPC over HTTP/2 | +| Authentication | Bearer token via `BearerTokenCallCredentials` | +| Token rotation | Supported via `Supplier` (re-evaluated per call) | +| TLS | Enabled by default (port 443), plaintext optional (port 50051) | +| Channel settings | Keep-alive 30s, max inbound 16MB | + +### Internal: Caffeine Cache + +- Optional dependency — must be explicitly added by consumer +- `CacheProvider` interface allows alternative implementations +- Currently only `CaffeineCacheProvider` shipped + +### Internal: SLF4J Logging + +- Facade only — no binding shipped +- Consumer must provide a binding (logback, log4j2, etc.) +- Logging levels used: DEBUG (cache hits), INFO (monitoring results), WARN (retry attempts), ERROR (query failures) + +### Spring Boot Integration + +- `@ConditionalOnProperty(prefix="frontegg.entitlements", name="enabled", havingValue="true", matchIfMissing=true)` +- Creates `EntitlementsClient` bean +- Supports `@ConfigurationProperties` binding +- Compatible with Spring Boot 3.2+ + +--- + +## Development and Deployment + +### Local Development Setup + +```bash +# Prerequisites: Java 17+, Maven 3.x +# Clone and build +git clone https://github.com/frontegg/entitlements-client-java.git +cd entitlements-client-java +mvn clean install # Build + unit tests + +# Run integration tests (requires Docker) +mvn verify -P integration # Starts SpiceDB via Testcontainers +``` + +### Build Commands + +```bash +mvn clean install # Build all modules + run unit tests +mvn verify -P integration # Run integration tests (Docker required) +mvn deploy -P release -DskipTests # Publish to Maven Central (CI only) +``` + +### CI/CD Pipeline + +**ci.yaml** (on push to master or PR): +- Matrix build: Java 17 + Java 21 +- Runs `mvn verify` (unit tests) +- Runs `mvn verify -P integration` (SpiceDB Testcontainers) +- Compiles all submodules + +**publish.yaml** (on tag `v*`): +1. Verifies GPG key (checks for missing/expired) +2. Sets version from git tag (`v0.1.0-beta.1` → `0.1.0-beta.1`) +3. Signs artifacts with GPG +4. Publishes all 4 modules to Maven Central via `central-publishing-maven-plugin` + +### Release Process + +- Semantic-release via `.releaserc.yaml` +- Branches: `master` (release), `next` (alpha prerelease) +- Conventional commits (`feat:`, `fix:`, `BREAKING CHANGE:`) +- Tag format: `v{version}` (e.g., `v0.1.0-beta.1`) + +--- + +## Testing Reality + +### Unit Tests (20 files) + +| Area | Files | What's Tested | +|--------------------|-------------------------------------------------|--------------------------------------| +| Configuration | `ClientConfigurationTest`, `InternalClientFactoryTest` | Builder validation, defaults, endpoint parsing | +| Models | `ModelRecordValidationTest`, `LookupModelsTest` | Sealed interface exhaustiveness, immutability | +| Strategies | `Feature/Permission/Fga/Route/LookupSpiceDBQueryTest` | gRPC request construction, response mapping | +| Client | `SpiceDBEntitlementsClientTest` | Retry, fallback, caching, monitoring, async | +| Retry | `RetryHandlerTest` | Backoff calculation, retryable codes | +| Cache | `CaffeineCacheProviderTest` | Hit/miss, TTL, max size, concurrent access | +| Utilities | `Base64UtilsTest`, `BearerTokenCallCredentialsTest`, `CaveatContextBuilderTest` | Encoding, auth, Struct conversion | + +### Integration Tests + +- **Location**: `src/test/java/.../integration/SpiceDBIntegrationTest.java` +- **Infrastructure**: Testcontainers + `SpiceDBContainer` (custom Docker container helper) +- **Schema**: `SpiceDBSchemaWriter` initializes test schemas +- **Profile**: `-P integration` (Maven failsafe plugin) +- **Requirements**: Docker daemon running + +### Test Utilities for Consumers + +- `MockEntitlementsClient` — default all-denied, configurable per-check, records calls +- `RecordingEntitlementsClient` — wraps real client, records interactions + +--- + +## Technical Debt and Known Issues + +### Current Status: Minimal Debt (Beta) + +This is a v0.1.0-beta.1 codebase built from scratch. No legacy code or accumulated debt. + +### Noted Patterns and Constraints + +1. **`BearerTokenCallCredentials` uses deprecated gRPC API**: The `applyRequestMetadata` method overrides a deprecated gRPC `CallCredentials` API. This works but will need updating when gRPC removes it. + +2. **`tokenAuth` removed from pom.xml**: The `central-publishing-maven-plugin` 0.10.0 no longer supports the `tokenAuth` configuration parameter. It was removed during the v0.1.0-beta.1 publish debugging. + +3. **Multiple test constructors on `SpiceDBEntitlementsClient`**: Has 4 constructors — 1 production, 3 for test injection. This is intentional for testability but adds surface area. + +4. **Caveat context silently drops unsupported types**: `CaveatContextBuilder` ignores attribute values that aren't String/Number/Boolean/null without warning. This is by design but could surprise consumers. + +5. **Spring Boot Starter only supports `StaticFallback`**: The properties-based configuration (`fallback-result: true/false`) can only create a `StaticFallback`. `FunctionFallback` requires programmatic bean definition. + +6. **No Route pattern pre-compilation**: `RouteSpiceDBQuery` sends route patterns as-is to SpiceDB. Route regex compilation happens server-side, not in the client. + +### Constraints to Respect + +- **Java 17 minimum** — Sealed interfaces and records are core to the API; cannot drop to 11/8 +- **Caffeine is optional** — Don't make it a required dependency; the `CacheProvider` interface exists for alternatives +- **Base64 encoding must be URL-safe without padding** — Cross-SDK compatibility with TypeScript SDK +- **Package-private `internal` package** — Don't expose internal classes in public API +- **Immutable models** — All public model types are records or sealed interfaces; don't add mutability + +--- + +## Appendix — Useful Commands and Scripts + +### Frequently Used Commands + +```bash +mvn clean install # Full build + unit tests +mvn verify -P integration # Integration tests (Docker required) +mvn versions:set -DnewVersion=X.Y.Z # Set version across modules +mvn dependency:tree # Inspect dependency graph +``` + +### Debugging and Troubleshooting + +- **gRPC debug logging**: Set `io.grpc` logger to DEBUG in your SLF4J config +- **Cache behavior**: Set `com.frontegg.sdk.entitlements` logger to DEBUG for cache hit/miss logs +- **SpiceDB connectivity**: Test endpoint with `grpcurl` before troubleshooting the SDK +- **Integration test failures**: Ensure Docker daemon is running and ports 50051/50052 are free diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java index 6c7bc37..26100a7 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java @@ -83,6 +83,12 @@ EntitlementsResult query(EntitySubjectContext entityCtx, EntityRequestContext re CheckPermissionResponse response = executor.execute(request); + log.info("FGA raw response permissionship={} subject={}:{} resource={}:{} permission={}", + response.getPermissionship(), + entityCtx.entityType(), b64EntityId, + requestCtx.resourceType(), b64ResourceId, + requestCtx.relation()); + boolean allowed = response.getPermissionship() == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION || response.getPermissionship() diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java index b930b17..a955f91 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java @@ -78,7 +78,9 @@ LookupResult lookupResources(LookupResourcesRequest request) { List resourceIds = new ArrayList<>(); while (responseIterator.hasNext()) { LookupResourcesResponse resp = responseIterator.next(); - resourceIds.add(Base64Utils.decode(resp.getResourceObjectId())); + String decoded = Base64Utils.decode(resp.getResourceObjectId()); + log.info("LookupResources found resource: {} (raw={})", decoded, resp.getResourceObjectId()); + resourceIds.add(decoded); } log.debug("LookupResources found {} resources subjectType={} subjectId={}", diff --git a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java index 36dcf96..83e1a41 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java +++ b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java @@ -85,9 +85,10 @@ caveat active_at(at timestamp, activeFrom any, activeUntil any) { } """; - schemaStub.writeSchema(WriteSchemaRequest.newBuilder() + var schemaResponse = schemaStub.writeSchema(WriteSchemaRequest.newBuilder() .setSchema(schema) .build()); + System.out.println("=== DEBUG: writeSchema response writtenAt=" + schemaResponse.getWrittenAt() + " ==="); } /** @@ -130,7 +131,8 @@ public void writeRelationships() { .addUpdates(buildUpdate("document", encode("doc-2"), "editor", "frontegg_user", encode("user-1"))) .build(); - permissionsStub.writeRelationships(request); + var response = permissionsStub.writeRelationships(request); + System.out.println("=== DEBUG: writeRelationships response writtenAt=" + response.getWrittenAt() + " ==="); } /** @@ -176,7 +178,8 @@ public void writeCaveatRelationships() { "folder", encode("salaries"), "active_at", "2026-03-01T00:00:00.000Z", null)) .build(); - permissionsStub.writeRelationships(request); + var caveatResponse = permissionsStub.writeRelationships(request); + System.out.println("=== DEBUG: writeCaveatRelationships response writtenAt=" + caveatResponse.getWrittenAt() + " ==="); } private static RelationshipUpdate buildUpdate(String resourceType, String resourceId, @@ -266,6 +269,9 @@ public String readSchema() { public java.util.List readRelationships(String resourceType) { var response = permissionsStub.readRelationships( com.authzed.api.v1.ReadRelationshipsRequest.newBuilder() + .setConsistency(com.authzed.api.v1.Consistency.newBuilder() + .setFullyConsistent(true) + .build()) .setRelationshipFilter(com.authzed.api.v1.RelationshipFilter.newBuilder() .setResourceType(resourceType) .build()) From 87c0cad86f01828aec5bca3dbf752b47ce7a9133 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 17:02:51 +0200 Subject: [PATCH 24/28] chore: remove accidentally committed bmad doc Co-Authored-By: Claude Opus 4.6 --- docs/brownfield-architecture.md | 507 -------------------------------- 1 file changed, 507 deletions(-) delete mode 100644 docs/brownfield-architecture.md diff --git a/docs/brownfield-architecture.md b/docs/brownfield-architecture.md deleted file mode 100644 index f393de3..0000000 --- a/docs/brownfield-architecture.md +++ /dev/null @@ -1,507 +0,0 @@ -# Entitlements Client Java SDK — Brownfield Architecture Document - -## Introduction - -This document captures the **current state** of the `entitlements-client-java` codebase as of v0.1.0-beta.1, including actual patterns, implementation details, technical constraints, and integration points. It serves as a reference for AI agents and developers working on enhancements to the SDK. - -This is a **client library** (SDK) — not a service. It wraps Frontegg's SpiceDB-backed ReBAC authorization engine via gRPC, enabling JVM applications to perform real-time permission checks. - -### Document Scope - -Comprehensive documentation of entire system. - -### Change Log - -| Date | Version | Description | Author | -|------------|---------|---------------------------------|--------------| -| 2026-03-11 | 1.0 | Initial brownfield analysis | AI-assisted | - ---- - -## Quick Reference — Key Files and Entry Points - -### Critical Files for Understanding the System - -- **Public API Interface**: `src/main/java/com/frontegg/sdk/entitlements/EntitlementsClient.java` -- **Client Factory**: `src/main/java/com/frontegg/sdk/entitlements/EntitlementsClientFactory.java` -- **Configuration**: `src/main/java/com/frontegg/sdk/entitlements/config/ClientConfiguration.java` -- **Query Dispatcher**: `src/main/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClient.java` -- **Strategy Implementations**: `src/main/java/com/frontegg/sdk/entitlements/internal/Feature|Permission|Route|FgaSpiceDBQuery.java` -- **Retry Logic**: `src/main/java/com/frontegg/sdk/entitlements/internal/RetryHandler.java` -- **Cache Layer**: `src/main/java/com/frontegg/sdk/entitlements/cache/CaffeineCacheProvider.java` -- **Fallback Strategies**: `src/main/java/com/frontegg/sdk/entitlements/fallback/FallbackStrategy.java` -- **Spring Boot Auto-Config**: `entitlements-client-spring-boot-starter/src/main/java/.../EntitlementsAutoConfiguration.java` -- **Test Mock**: `entitlements-client-test/src/main/java/.../MockEntitlementsClient.java` - -### Build & CI - -- **Root POM**: `pom.xml` -- **CI Pipeline**: `.github/workflows/ci.yaml` (matrix: Java 17, 21) -- **Publish Pipeline**: `.github/workflows/publish.yaml` (tag-triggered, Maven Central) -- **Release Config**: `.releaserc.yaml` (semantic-release, conventional commits) - ---- - -## High Level Architecture - -### Technical Summary - -| Category | Technology | Version | Notes | -|------------------|-------------------|---------|------------------------------------------| -| Language | Java | 17+ | Sealed interfaces, records used heavily | -| Build | Maven | 3.x | Multi-module project | -| gRPC Transport | grpc-netty-shaded | 1.78.0 | Shaded Netty for conflict avoidance | -| gRPC Client | grpc-stub | 1.78.0 | gRPC stubs for SpiceDB API | -| SpiceDB Client | authzed-java | 1.5.4 | Authzed's official Java client | -| Protobuf | protobuf-java | 4.33.5 | Protocol Buffers runtime | -| Caching | Caffeine | 3.1.8 | Optional dependency, LRU + TTL | -| Logging | SLF4J | 2.0.16 | Facade only — no runtime binding shipped | -| Spring Boot | autoconfigure | 3.2.0 | Provided scope in starter module | -| Testing | JUnit Jupiter | 5.10.3 | Unit tests | -| Testing | Mockito | 5.14.2 | Mocking framework | -| Testing | Testcontainers | 1.19.8 | Docker-based SpiceDB integration tests | - -### Repository Structure - -Type: **Multi-module Maven project** - -```text -entitlements-client-java/ -├── src/ # Core SDK (44 Java source files) -│ ├── main/java/com/frontegg/sdk/entitlements/ -│ │ ├── EntitlementsClient.java # Public API interface (sealed) -│ │ ├── EntitlementsClientFactory.java # Static factory, validates config -│ │ ├── config/ -│ │ │ ├── ClientConfiguration.java # Builder pattern, immutable -│ │ │ └── CacheConfiguration.java # Cache settings (maxSize, TTL) -│ │ ├── model/ -│ │ │ ├── SubjectContext.java # Sealed: User | Entity subject -│ │ │ ├── RequestContext.java # Sealed: Feature | Permission | Route | Entity -│ │ │ ├── EntitlementsResult.java # Record: result + monitoring flag -│ │ │ ├── LookupResult.java # Record: list of entity IDs -│ │ │ ├── LookupResourcesRequest.java # Lookup resources request -│ │ │ └── LookupSubjectsRequest.java # Lookup subjects request -│ │ ├── exception/ -│ │ │ ├── EntitlementsException.java # Base exception -│ │ │ ├── EntitlementsQueryException.java # SpiceDB gRPC errors -│ │ │ ├── EntitlementsTimeoutException.java # DEADLINE_EXCEEDED -│ │ │ ├── ConfigurationMissingException.java # Required field absent -│ │ │ └── ConfigurationInvalidException.java # Invalid field value -│ │ ├── fallback/ -│ │ │ ├── FallbackStrategy.java # Sealed interface -│ │ │ ├── StaticFallback.java # Fixed true/false result -│ │ │ ├── FunctionFallback.java # Custom function handler -│ │ │ └── FallbackContext.java # Context passed to handler -│ │ ├── cache/ -│ │ │ ├── CacheProvider.java # Generic K,V cache interface -│ │ │ └── CaffeineCacheProvider.java # Caffeine-backed implementation -│ │ └── internal/ # Package-private implementation -│ │ ├── SpiceDBEntitlementsClient.java # Core client implementation -│ │ ├── SpiceDBQueryClient.java # Strategy dispatcher -│ │ ├── InternalClientFactory.java # gRPC channel factory -│ │ ├── FeatureSpiceDBQuery.java # Feature check (OR semantics) -│ │ ├── PermissionSpiceDBQuery.java # Permission check (AND semantics) -│ │ ├── RouteSpiceDBQuery.java # Route check (regex matching) -│ │ ├── FgaSpiceDBQuery.java # FGA check (single relation) -│ │ ├── LookupSpiceDBQuery.java # Lookup operations (streaming) -│ │ ├── RetryHandler.java # Exponential backoff retry -│ │ ├── BearerTokenCallCredentials.java # gRPC auth credentials -│ │ ├── CaveatContextBuilder.java # Attribute map → Protobuf Struct -│ │ ├── Base64Utils.java # URL-safe Base64 (no padding) -│ │ └── EntitlementsCacheKey.java # Cache key record -│ └── test/java/com/frontegg/sdk/entitlements/ # 20 test files -│ ├── config/ # Configuration tests -│ ├── internal/ # Strategy & client tests -│ ├── cache/ # Cache behavior tests -│ └── integration/ # SpiceDB Testcontainers tests -├── entitlements-client-bom/ # Bill of Materials (version alignment) -│ └── pom.xml -├── entitlements-client-spring-boot-starter/ # Spring Boot auto-configuration -│ └── src/main/java/.../ -│ ├── EntitlementsAutoConfiguration.java -│ └── EntitlementsProperties.java -├── entitlements-client-test/ # Test utilities for consumers -│ └── src/main/java/.../ -│ ├── MockEntitlementsClient.java # Configurable mock -│ └── RecordingEntitlementsClient.java # Spy wrapper -├── .github/workflows/ -│ ├── ci.yaml # Build + test matrix -│ └── publish.yaml # Maven Central publish -├── pom.xml # Root POM -├── .releaserc.yaml # Semantic-release config -└── docs/ # Documentation -``` - -### Published Maven Artifacts (4) - -| Artifact | Type | Consumers | Contains | -|---------------------------------------------|------|--------------------|---------------------------------------------------- -| `com.frontegg.sdk:entitlements-client` | jar | All JVM apps | Core gRPC client, config, models, cache, fallback | -| `com.frontegg.sdk:entitlements-client-bom` | pom | Multi-SDK projects | Version alignment for all artifacts + dependencies | -| `com.frontegg.sdk:entitlements-client-spring-boot-starter` | jar | Spring Boot apps | Auto-configuration + properties binding | -| `com.frontegg.sdk:entitlements-client-test` | jar | Test suites | MockEntitlementsClient, RecordingEntitlementsClient | - ---- - -## Source Tree and Module Organization - -### Key Modules and Their Purpose - -#### Core SDK (`src/main/java`) -The heart of the library. 44 source files organized into public API surface (`EntitlementsClient`, `EntitlementsClientFactory`, `config.*`, `model.*`, `exception.*`, `fallback.*`, `cache.*`) and package-private implementation (`internal.*`). - -**Design principle**: All `internal` classes are package-private. Consumers interact only through: -- `EntitlementsClient` interface (sealed) -- `EntitlementsClientFactory.create(config)` factory method -- Immutable records and sealed interfaces for models - -#### Spring Boot Starter (`entitlements-client-spring-boot-starter/`) -Auto-configuration that creates an `EntitlementsClient` bean from `application.yml` properties. Activated by default when on classpath; disabled via `frontegg.entitlements.enabled=false`. - -#### Test Utilities (`entitlements-client-test/`) -Two test doubles for consumers: -- `MockEntitlementsClient` — configurable mock (default: all denied), records calls -- `RecordingEntitlementsClient` — spy that wraps a real client and records interactions - -#### BOM (`entitlements-client-bom/`) -Manages versions of all four Frontegg artifacts plus transitive dependencies (gRPC, Protobuf, authzed-java, SLF4J, Caffeine). - ---- - -## Architecture Patterns — Actual Implementation - -### 1. Strategy Pattern — Query Dispatch - -`SpiceDBQueryClient` routes requests based on `RequestContext` type via sealed interface pattern matching: - -| RequestContext Type | Strategy Class | SpiceDB RPC Used | Semantics | -|--------------------------|--------------------------|--------------------------|------------------| -| `FeatureRequestContext` | `FeatureSpiceDBQuery` | `checkBulkPermissions` | OR (user OR tenant) | -| `PermissionRequestContext` | `PermissionSpiceDBQuery` | `checkBulkPermissions` | AND (all permissions) | -| `RouteRequestContext` | `RouteSpiceDBQuery` | `checkBulkPermissions` | Regex route match | -| `EntityRequestContext` | `FgaSpiceDBQuery` | `checkPermission` | Single relation | - -**Key detail**: Feature checks send 2 items (user + tenant) and return true if **either** is entitled. Permission checks send 2N items (N permissions x 2 subjects) and return true only if **all** are entitled. - -### 2. gRPC Channel Management - -`InternalClientFactory` creates a single shared `ManagedChannel`: -- Uses `NettyChannelBuilder` (shaded transport) -- Keep-alive: 30s interval, 10s timeout -- Max inbound message: 16MB -- Port defaults: 443 (TLS) / 50051 (plaintext) -- `BearerTokenCallCredentials` reads token from `Supplier` on every call (supports rotation) - -### 3. Retry with Exponential Backoff - -`RetryHandler` retries only transient gRPC errors: -- **Retryable**: `UNAVAILABLE`, `DEADLINE_EXCEEDED` -- **Non-retryable**: All others (fail immediately) -- **Backoff formula**: `min(200ms * 2^attempt, 2000ms) + random(0-100ms)` -- **Max retries**: Configurable (default 3) - -### 4. Caching Layer - -`CaffeineCacheProvider` provides optional in-memory caching: -- **Key**: `EntitlementsCacheKey(subjectContext, requestContext)` — composite record -- **Defaults**: 10,000 max entries, 60s TTL (write-time expiry) -- **Rule**: Only caches successful, non-monitoring results -- **Dependency**: Caffeine is `true` — consumers must add it explicitly - -### 5. Fallback Strategy (Sealed) - -When all retries are exhausted: -- `StaticFallback(boolean)` — always returns fixed result -- `FunctionFallback(Function)` — custom logic with full context (subject, request, cause) -- No fallback configured → exception propagates to caller -- **Monitoring mode bypasses fallback** (always returns allowed) - -### 6. Monitoring Mode - -Non-enforcement observability: -- Executes the real SpiceDB check -- Logs result at INFO level -- **Always returns** `EntitlementsResult(true, monitoring=true)` regardless of actual result -- Fallback is not invoked in monitoring mode - -### 7. Base64 Encoding - -All SpiceDB object IDs are Base64-encoded before sending and decoded on return: -- **RFC 4648 Section 5** — URL-safe alphabet (`-_` instead of `+/`) -- **No padding** — matches the TypeScript SDK's `normalizeObjectId` -- Cross-SDK compatibility requirement - -### 8. Caveat Context Builder - -Converts user attributes `Map` + optional `Instant` into Protobuf `Struct`: -- Supports: String, Number, Boolean, null -- Unsupported types silently skipped -- Time parameter formatted as ISO-8601 string under key `"at"` -- Returns `null` (not empty Struct) when nothing to encode — SpiceDB treats them differently - ---- - -## Data Models and APIs - -### Subject Context Hierarchy (Sealed) - -```java -SubjectContext (sealed) -├── UserSubjectContext(String userId, String tenantId, Map attributes) -│ // Encodes as: frontegg_user: + frontegg_tenant: -└── EntitySubjectContext(String entityType, String entityId) - // Encodes as: : -``` - -### Request Context Hierarchy (Sealed) - -```java -RequestContext (sealed) -├── FeatureRequestContext(String featureKey, Instant at) -│ // Checks: frontegg_feature:#entitled -├── PermissionRequestContext(List permissionKeys, Instant at) -│ // Checks: frontegg_permission:#entitled for each key -├── RouteRequestContext(String method, String path, Instant at) -│ // Checks: frontegg_route:#entitled -└── EntityRequestContext(String resourceType, String resourceId, String relation) - // Checks: :# -``` - -### Result Models (Records) - -- `EntitlementsResult(boolean result, boolean monitoring)` — entitlement check result -- `LookupResult(List entityIds)` — immutable list of matched IDs -- `LookupResourcesRequest(String subjectType, String subjectId, String permission, String resourceType)` -- `LookupSubjectsRequest(String resourceType, String resourceId, String permission, String subjectType)` - -### Exception Hierarchy - -```java -EntitlementsException (extends RuntimeException) -├── ConfigurationMissingException // Required config field absent -├── ConfigurationInvalidException // Invalid config field value -└── EntitlementsQueryException // SpiceDB gRPC error (carries Status.Code) - └── EntitlementsTimeoutException // DEADLINE_EXCEEDED specifically -``` - -### SpiceDB gRPC RPCs Used - -| RPC | Used By | Semantics | -|------------------------|----------------------------|-------------------------------------| -| `CheckPermission` | `FgaSpiceDBQuery` | Single entity-to-entity check | -| `CheckBulkPermissions` | `Feature/Permission/Route` | Batch check multiple tuples | -| `LookupResources` | `LookupSpiceDBQuery` | Find all resource IDs (streaming) | -| `LookupSubjects` | `LookupSpiceDBQuery` | Find all subject IDs (streaming) | - ---- - -## Configuration - -### Direct API Usage - -```java -ClientConfiguration config = ClientConfiguration.builder() - .engineEndpoint("grpc.authz.example.com:443") // Required - .engineToken("your-spicedb-token") // Required - .requestTimeout(Duration.ofSeconds(5)) // Default: 5s - .bulkRequestTimeout(Duration.ofSeconds(15)) // Default: 15s - .maxRetries(3) // Default: 3 - .useTls(true) // Default: true - .monitoring(false) // Default: false - .cacheConfiguration(CacheConfiguration.defaults()) // Optional - .fallbackStrategy(new StaticFallback(false)) // Optional - .build(); - -EntitlementsClient client = EntitlementsClientFactory.create(config); -``` - -### Spring Boot Properties - -```yaml -frontegg: - entitlements: - enabled: true # default: true - engine-endpoint: grpc.authz.example.com:443 - engine-token: ${ENTITLEMENTS_ENGINE_TOKEN} - use-tls: true # default: true - request-timeout: 5s # default: 5s - bulk-request-timeout: 15s # default: 15s - max-retries: 3 # default: 3 - monitoring: false # default: false - fallback-result: false # null = no fallback (static only) - cache: - max-size: 10000 # default: 10000 - expire-after-write: 60s # default: 60s -``` - -### Configuration Defaults and Constraints - -| Field | Default | Validation | -|--------------------|---------|---------------------------------------------| -| engineEndpoint | — | Required, non-blank | -| engineToken | — | Required, non-blank | -| requestTimeout | 5s | Must be positive | -| bulkRequestTimeout | 15s | Must be positive | -| maxRetries | 3 | Must be >= 0 | -| useTls | true | — | -| monitoring | false | — | -| cacheConfiguration | null | If provided, maxSize > 0, expiry > 0 | -| fallbackStrategy | null | If provided, must be StaticFallback or FunctionFallback | - ---- - -## Integration Points and External Dependencies - -### External: SpiceDB Authorization Engine - -| Aspect | Detail | -|-------------------|-------------------------------------------------------| -| Protocol | gRPC over HTTP/2 | -| Authentication | Bearer token via `BearerTokenCallCredentials` | -| Token rotation | Supported via `Supplier` (re-evaluated per call) | -| TLS | Enabled by default (port 443), plaintext optional (port 50051) | -| Channel settings | Keep-alive 30s, max inbound 16MB | - -### Internal: Caffeine Cache - -- Optional dependency — must be explicitly added by consumer -- `CacheProvider` interface allows alternative implementations -- Currently only `CaffeineCacheProvider` shipped - -### Internal: SLF4J Logging - -- Facade only — no binding shipped -- Consumer must provide a binding (logback, log4j2, etc.) -- Logging levels used: DEBUG (cache hits), INFO (monitoring results), WARN (retry attempts), ERROR (query failures) - -### Spring Boot Integration - -- `@ConditionalOnProperty(prefix="frontegg.entitlements", name="enabled", havingValue="true", matchIfMissing=true)` -- Creates `EntitlementsClient` bean -- Supports `@ConfigurationProperties` binding -- Compatible with Spring Boot 3.2+ - ---- - -## Development and Deployment - -### Local Development Setup - -```bash -# Prerequisites: Java 17+, Maven 3.x -# Clone and build -git clone https://github.com/frontegg/entitlements-client-java.git -cd entitlements-client-java -mvn clean install # Build + unit tests - -# Run integration tests (requires Docker) -mvn verify -P integration # Starts SpiceDB via Testcontainers -``` - -### Build Commands - -```bash -mvn clean install # Build all modules + run unit tests -mvn verify -P integration # Run integration tests (Docker required) -mvn deploy -P release -DskipTests # Publish to Maven Central (CI only) -``` - -### CI/CD Pipeline - -**ci.yaml** (on push to master or PR): -- Matrix build: Java 17 + Java 21 -- Runs `mvn verify` (unit tests) -- Runs `mvn verify -P integration` (SpiceDB Testcontainers) -- Compiles all submodules - -**publish.yaml** (on tag `v*`): -1. Verifies GPG key (checks for missing/expired) -2. Sets version from git tag (`v0.1.0-beta.1` → `0.1.0-beta.1`) -3. Signs artifacts with GPG -4. Publishes all 4 modules to Maven Central via `central-publishing-maven-plugin` - -### Release Process - -- Semantic-release via `.releaserc.yaml` -- Branches: `master` (release), `next` (alpha prerelease) -- Conventional commits (`feat:`, `fix:`, `BREAKING CHANGE:`) -- Tag format: `v{version}` (e.g., `v0.1.0-beta.1`) - ---- - -## Testing Reality - -### Unit Tests (20 files) - -| Area | Files | What's Tested | -|--------------------|-------------------------------------------------|--------------------------------------| -| Configuration | `ClientConfigurationTest`, `InternalClientFactoryTest` | Builder validation, defaults, endpoint parsing | -| Models | `ModelRecordValidationTest`, `LookupModelsTest` | Sealed interface exhaustiveness, immutability | -| Strategies | `Feature/Permission/Fga/Route/LookupSpiceDBQueryTest` | gRPC request construction, response mapping | -| Client | `SpiceDBEntitlementsClientTest` | Retry, fallback, caching, monitoring, async | -| Retry | `RetryHandlerTest` | Backoff calculation, retryable codes | -| Cache | `CaffeineCacheProviderTest` | Hit/miss, TTL, max size, concurrent access | -| Utilities | `Base64UtilsTest`, `BearerTokenCallCredentialsTest`, `CaveatContextBuilderTest` | Encoding, auth, Struct conversion | - -### Integration Tests - -- **Location**: `src/test/java/.../integration/SpiceDBIntegrationTest.java` -- **Infrastructure**: Testcontainers + `SpiceDBContainer` (custom Docker container helper) -- **Schema**: `SpiceDBSchemaWriter` initializes test schemas -- **Profile**: `-P integration` (Maven failsafe plugin) -- **Requirements**: Docker daemon running - -### Test Utilities for Consumers - -- `MockEntitlementsClient` — default all-denied, configurable per-check, records calls -- `RecordingEntitlementsClient` — wraps real client, records interactions - ---- - -## Technical Debt and Known Issues - -### Current Status: Minimal Debt (Beta) - -This is a v0.1.0-beta.1 codebase built from scratch. No legacy code or accumulated debt. - -### Noted Patterns and Constraints - -1. **`BearerTokenCallCredentials` uses deprecated gRPC API**: The `applyRequestMetadata` method overrides a deprecated gRPC `CallCredentials` API. This works but will need updating when gRPC removes it. - -2. **`tokenAuth` removed from pom.xml**: The `central-publishing-maven-plugin` 0.10.0 no longer supports the `tokenAuth` configuration parameter. It was removed during the v0.1.0-beta.1 publish debugging. - -3. **Multiple test constructors on `SpiceDBEntitlementsClient`**: Has 4 constructors — 1 production, 3 for test injection. This is intentional for testability but adds surface area. - -4. **Caveat context silently drops unsupported types**: `CaveatContextBuilder` ignores attribute values that aren't String/Number/Boolean/null without warning. This is by design but could surprise consumers. - -5. **Spring Boot Starter only supports `StaticFallback`**: The properties-based configuration (`fallback-result: true/false`) can only create a `StaticFallback`. `FunctionFallback` requires programmatic bean definition. - -6. **No Route pattern pre-compilation**: `RouteSpiceDBQuery` sends route patterns as-is to SpiceDB. Route regex compilation happens server-side, not in the client. - -### Constraints to Respect - -- **Java 17 minimum** — Sealed interfaces and records are core to the API; cannot drop to 11/8 -- **Caffeine is optional** — Don't make it a required dependency; the `CacheProvider` interface exists for alternatives -- **Base64 encoding must be URL-safe without padding** — Cross-SDK compatibility with TypeScript SDK -- **Package-private `internal` package** — Don't expose internal classes in public API -- **Immutable models** — All public model types are records or sealed interfaces; don't add mutability - ---- - -## Appendix — Useful Commands and Scripts - -### Frequently Used Commands - -```bash -mvn clean install # Full build + unit tests -mvn verify -P integration # Integration tests (Docker required) -mvn versions:set -DnewVersion=X.Y.Z # Set version across modules -mvn dependency:tree # Inspect dependency graph -``` - -### Debugging and Troubleshooting - -- **gRPC debug logging**: Set `io.grpc` logger to DEBUG in your SLF4J config -- **Cache behavior**: Set `com.frontegg.sdk.entitlements` logger to DEBUG for cache hit/miss logs -- **SpiceDB connectivity**: Test endpoint with `grpcurl` before troubleshooting the SDK -- **Integration test failures**: Ensure Docker daemon is running and ports 50051/50052 are free From 0f9564a54e6f08f6b4cf41a25e56cd649fd887c3 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 17:11:04 +0200 Subject: [PATCH 25/28] debug(e2e): add direct CheckPermission bypass to diagnose SDK vs SpiceDB Compare direct gRPC CheckPermission (with fully_consistent) against SDK's CheckPermission to identify if the issue is consistency, authentication, or something else. Co-Authored-By: Claude Opus 4.6 --- .../sdk/entitlements/e2e/SpiceDBE2ETest.java | 20 ++++++++--- .../integration/SpiceDBSchemaWriter.java | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java index 647189d..1d8287f 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java @@ -92,19 +92,29 @@ static void setup() { client = EntitlementsClientFactory.create(config); - // Debug: test a simple check right after setup - System.out.println("=== DEBUG: Testing Tim read_doc Tim's_salary_Jan at 2026-01-01 ==="); + // Direct check via schema writer stub (bypasses SDK, uses fully_consistent) + String directResult = schemaWriter.directCheckPermission( + "frontegg_user", "Tim", "document", "Tim's_salary_Jan", + "read_doc", "2026-01-01T00:00:00Z"); + System.out.println("=== DEBUG: DIRECT CheckPermission result=" + directResult + " ==="); + + String directNoAt = schemaWriter.directCheckPermission( + "frontegg_user", "Tim", "document", "Tim's_salary_Jan", + "read_doc", null); + System.out.println("=== DEBUG: DIRECT CheckPermission without at=" + directNoAt + " ==="); + + // SDK check for comparison + System.out.println("=== DEBUG: Testing SDK Tim read_doc Tim's_salary_Jan at 2026-01-01 ==="); EntitlementsResult debugResult = client.isEntitledTo( new EntitySubjectContext("frontegg_user", "Tim"), new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", Instant.parse("2026-01-01T00:00:00Z"))); - System.out.println("=== DEBUG: result=" + debugResult.result() + " ==="); + System.out.println("=== DEBUG: SDK result=" + debugResult.result() + " ==="); - // Also test WITHOUT caveat context to see if relationship exists at all EntitlementsResult debugResult2 = client.isEntitledTo( new EntitySubjectContext("frontegg_user", "Tim"), new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", null)); - System.out.println("=== DEBUG: result without at=" + debugResult2.result() + " ==="); + System.out.println("=== DEBUG: SDK result without at=" + debugResult2.result() + " ==="); } @AfterAll diff --git a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java index 83e1a41..5d9afa3 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java +++ b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java @@ -250,6 +250,41 @@ static String encode(String value) { .encodeToString(value.getBytes(StandardCharsets.UTF_8)); } + /** + * Performs a direct CheckPermission call for diagnostics, bypassing the SDK. + */ + public String directCheckPermission(String subjectType, String subjectId, + String resourceType, String resourceId, + String permission, String atTimestamp) { + com.authzed.api.v1.CheckPermissionRequest.Builder reqBuilder = + com.authzed.api.v1.CheckPermissionRequest.newBuilder() + .setConsistency(com.authzed.api.v1.Consistency.newBuilder() + .setFullyConsistent(true) + .build()) + .setSubject(com.authzed.api.v1.SubjectReference.newBuilder() + .setObject(ObjectReference.newBuilder() + .setObjectType(subjectType) + .setObjectId(encode(subjectId)) + .build()) + .build()) + .setResource(ObjectReference.newBuilder() + .setObjectType(resourceType) + .setObjectId(encode(resourceId)) + .build()) + .setPermission(permission); + + if (atTimestamp != null) { + reqBuilder.setContext(com.google.protobuf.Struct.newBuilder() + .putFields("at", com.google.protobuf.Value.newBuilder() + .setStringValue(atTimestamp) + .build()) + .build()); + } + + var resp = permissionsStub.checkPermission(reqBuilder.build()); + return resp.getPermissionship().name(); + } + /** * Reads back the current schema from SpiceDB (for diagnostics). * From eb2a12e12e315b00c5909e16ec35e9b61fe6684c Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 17:23:47 +0200 Subject: [PATCH 26/28] fix: use fully_consistent reads for all SpiceDB gRPC requests Root cause: SpiceDB's default `minimize_latency` consistency can return stale results on the memory datastore when relationships are written and immediately queried. This caused all caveat-based E2E tests to fail in CI (CheckPermission returned NO_PERMISSION despite relationships existing). Set `Consistency.fully_consistent=true` on all CheckPermission, CheckBulkPermissions, LookupResources, and LookupSubjects requests. Also remove debug diagnostic logging from E2E tests and schema writer. --- .../internal/FeatureSpiceDBQuery.java | 2 + .../internal/FgaSpiceDBQuery.java | 10 +-- .../internal/LookupSpiceDBQuery.java | 7 +- .../internal/PermissionSpiceDBQuery.java | 4 +- .../internal/RouteSpiceDBQuery.java | 2 + .../sdk/entitlements/e2e/SpiceDBE2ETest.java | 65 +-------------- .../integration/SpiceDBSchemaWriter.java | 82 +------------------ 7 files changed, 21 insertions(+), 151 deletions(-) diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java index a0e9419..0726cf5 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java @@ -1,6 +1,7 @@ package com.frontegg.sdk.entitlements.internal; import com.authzed.api.v1.CheckBulkPermissionsPair; +import com.authzed.api.v1.Consistency; import com.authzed.api.v1.CheckBulkPermissionsRequest; import com.authzed.api.v1.CheckBulkPermissionsRequestItem; import com.authzed.api.v1.CheckBulkPermissionsResponse; @@ -76,6 +77,7 @@ EntitlementsResult query(UserSubjectContext userCtx, FeatureRequestContext featu TYPE_TENANT, b64TenantId, featureResource, caveatContext); CheckBulkPermissionsRequest request = CheckBulkPermissionsRequest.newBuilder() + .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()) .addItems(userItem) .addItems(tenantItem) .build(); diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java index 26100a7..faad3aa 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java @@ -2,6 +2,7 @@ import com.authzed.api.v1.CheckPermissionRequest; import com.authzed.api.v1.CheckPermissionResponse; +import com.authzed.api.v1.Consistency; import com.authzed.api.v1.ObjectReference; import com.authzed.api.v1.SubjectReference; import com.frontegg.sdk.entitlements.model.EntitlementsResult; @@ -71,6 +72,9 @@ EntitlementsResult query(EntitySubjectContext entityCtx, EntityRequestContext re Struct caveatContext = CaveatContextBuilder.build(null, requestCtx.at()); CheckPermissionRequest.Builder requestBuilder = CheckPermissionRequest.newBuilder() + .setConsistency(Consistency.newBuilder() + .setFullyConsistent(true) + .build()) .setSubject(subject) .setResource(resource) .setPermission(requestCtx.relation()); @@ -83,12 +87,6 @@ EntitlementsResult query(EntitySubjectContext entityCtx, EntityRequestContext re CheckPermissionResponse response = executor.execute(request); - log.info("FGA raw response permissionship={} subject={}:{} resource={}:{} permission={}", - response.getPermissionship(), - entityCtx.entityType(), b64EntityId, - requestCtx.resourceType(), b64ResourceId, - requestCtx.relation()); - boolean allowed = response.getPermissionship() == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION || response.getPermissionship() diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java index a955f91..e00d46b 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java @@ -1,5 +1,6 @@ package com.frontegg.sdk.entitlements.internal; +import com.authzed.api.v1.Consistency; import com.authzed.api.v1.LookupResourcesResponse; import com.authzed.api.v1.LookupSubjectsResponse; import com.authzed.api.v1.ObjectReference; @@ -57,6 +58,7 @@ LookupResult lookupResources(LookupResourcesRequest request) { com.authzed.api.v1.LookupResourcesRequest.Builder grpcRequestBuilder = com.authzed.api.v1.LookupResourcesRequest.newBuilder() + .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()) .setSubject(SubjectReference.newBuilder() .setObject(ObjectReference.newBuilder() .setObjectType(request.subjectType()) @@ -78,9 +80,7 @@ LookupResult lookupResources(LookupResourcesRequest request) { List resourceIds = new ArrayList<>(); while (responseIterator.hasNext()) { LookupResourcesResponse resp = responseIterator.next(); - String decoded = Base64Utils.decode(resp.getResourceObjectId()); - log.info("LookupResources found resource: {} (raw={})", decoded, resp.getResourceObjectId()); - resourceIds.add(decoded); + resourceIds.add(Base64Utils.decode(resp.getResourceObjectId())); } log.debug("LookupResources found {} resources subjectType={} subjectId={}", @@ -106,6 +106,7 @@ LookupResult lookupSubjects(LookupSubjectsRequest request) { com.authzed.api.v1.LookupSubjectsRequest.Builder grpcRequestBuilder = com.authzed.api.v1.LookupSubjectsRequest.newBuilder() + .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()) .setResource(ObjectReference.newBuilder() .setObjectType(request.resourceType()) .setObjectId(b64ResourceId) diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java index a86e265..d66c2b9 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java @@ -1,6 +1,7 @@ package com.frontegg.sdk.entitlements.internal; import com.authzed.api.v1.CheckBulkPermissionsPair; +import com.authzed.api.v1.Consistency; import com.authzed.api.v1.CheckBulkPermissionsRequest; import com.authzed.api.v1.CheckBulkPermissionsRequestItem; import com.authzed.api.v1.CheckBulkPermissionsResponse; @@ -74,7 +75,8 @@ EntitlementsResult query(UserSubjectContext userCtx, PermissionRequestContext pe Struct caveatContext = CaveatContextBuilder.build(userCtx.attributes(), permissionCtx.at()); - CheckBulkPermissionsRequest.Builder requestBuilder = CheckBulkPermissionsRequest.newBuilder(); + CheckBulkPermissionsRequest.Builder requestBuilder = CheckBulkPermissionsRequest.newBuilder() + .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()); for (String permissionKey : permissionCtx.permissionKeys()) { String b64PermissionKey = Base64Utils.encode(permissionKey); diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java index 20cbf25..977decc 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java @@ -1,6 +1,7 @@ package com.frontegg.sdk.entitlements.internal; import com.authzed.api.v1.CheckBulkPermissionsPair; +import com.authzed.api.v1.Consistency; import com.authzed.api.v1.CheckBulkPermissionsRequest; import com.authzed.api.v1.CheckBulkPermissionsRequestItem; import com.authzed.api.v1.CheckBulkPermissionsResponse; @@ -81,6 +82,7 @@ EntitlementsResult query(UserSubjectContext userCtx, RouteRequestContext routeCt TYPE_TENANT, b64TenantId, routeResource, caveatContext); CheckBulkPermissionsRequest request = CheckBulkPermissionsRequest.newBuilder() + .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()) .addItems(userItem) .addItems(tenantItem) .build(); diff --git a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java index 1d8287f..5aca849 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java @@ -45,44 +45,9 @@ static void setup() { // Seed schema and relationships via gRPC (idempotent) schemaWriter = new SpiceDBSchemaWriter(endpoint, token); - try { - schemaWriter.writeSchema(); - System.out.println("=== DEBUG: Schema written OK ==="); - } catch (Exception e) { - System.out.println("=== DEBUG: Schema write FAILED: " + e.getMessage() + " ==="); - throw e; - } - try { - schemaWriter.writeRelationships(); - System.out.println("=== DEBUG: Relationships written OK ==="); - } catch (Exception e) { - System.out.println("=== DEBUG: Relationships write FAILED: " + e.getMessage() + " ==="); - throw e; - } - try { - schemaWriter.writeCaveatRelationships(); - System.out.println("=== DEBUG: Caveat relationships written OK ==="); - } catch (Exception e) { - System.out.println("=== DEBUG: Caveat relationships write FAILED: " + e.getMessage() + " ==="); - throw e; - } - - // Verify schema was actually written - String schema = schemaWriter.readSchema(); - System.out.println("=== DEBUG: Schema contains 'document': " + schema.contains("document") + " ==="); - System.out.println("=== DEBUG: Schema contains 'active_at': " + schema.contains("active_at") + " ==="); - - // Verify relationships were written - var docRels = schemaWriter.readRelationships("document"); - System.out.println("=== DEBUG: Document relationships count: " + docRels.size() + " ==="); - for (String rel : docRels) { - System.out.println("=== DEBUG: Rel: " + rel + " ==="); - } - var folderRels = schemaWriter.readRelationships("folder"); - System.out.println("=== DEBUG: Folder relationships count: " + folderRels.size() + " ==="); - for (String rel : folderRels) { - System.out.println("=== DEBUG: Rel: " + rel + " ==="); - } + schemaWriter.writeSchema(); + schemaWriter.writeRelationships(); + schemaWriter.writeCaveatRelationships(); ClientConfiguration config = ClientConfiguration.builder() .engineEndpoint(endpoint) @@ -91,30 +56,6 @@ static void setup() { .build(); client = EntitlementsClientFactory.create(config); - - // Direct check via schema writer stub (bypasses SDK, uses fully_consistent) - String directResult = schemaWriter.directCheckPermission( - "frontegg_user", "Tim", "document", "Tim's_salary_Jan", - "read_doc", "2026-01-01T00:00:00Z"); - System.out.println("=== DEBUG: DIRECT CheckPermission result=" + directResult + " ==="); - - String directNoAt = schemaWriter.directCheckPermission( - "frontegg_user", "Tim", "document", "Tim's_salary_Jan", - "read_doc", null); - System.out.println("=== DEBUG: DIRECT CheckPermission without at=" + directNoAt + " ==="); - - // SDK check for comparison - System.out.println("=== DEBUG: Testing SDK Tim read_doc Tim's_salary_Jan at 2026-01-01 ==="); - EntitlementsResult debugResult = client.isEntitledTo( - new EntitySubjectContext("frontegg_user", "Tim"), - new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", - Instant.parse("2026-01-01T00:00:00Z"))); - System.out.println("=== DEBUG: SDK result=" + debugResult.result() + " ==="); - - EntitlementsResult debugResult2 = client.isEntitledTo( - new EntitySubjectContext("frontegg_user", "Tim"), - new EntityRequestContext("document", "Tim's_salary_Jan", "read_doc", null)); - System.out.println("=== DEBUG: SDK result without at=" + debugResult2.result() + " ==="); } @AfterAll diff --git a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java index 5d9afa3..e19dd27 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java +++ b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java @@ -85,10 +85,9 @@ caveat active_at(at timestamp, activeFrom any, activeUntil any) { } """; - var schemaResponse = schemaStub.writeSchema(WriteSchemaRequest.newBuilder() + schemaStub.writeSchema(WriteSchemaRequest.newBuilder() .setSchema(schema) .build()); - System.out.println("=== DEBUG: writeSchema response writtenAt=" + schemaResponse.getWrittenAt() + " ==="); } /** @@ -131,8 +130,7 @@ public void writeRelationships() { .addUpdates(buildUpdate("document", encode("doc-2"), "editor", "frontegg_user", encode("user-1"))) .build(); - var response = permissionsStub.writeRelationships(request); - System.out.println("=== DEBUG: writeRelationships response writtenAt=" + response.getWrittenAt() + " ==="); + permissionsStub.writeRelationships(request); } /** @@ -178,8 +176,7 @@ public void writeCaveatRelationships() { "folder", encode("salaries"), "active_at", "2026-03-01T00:00:00.000Z", null)) .build(); - var caveatResponse = permissionsStub.writeRelationships(request); - System.out.println("=== DEBUG: writeCaveatRelationships response writtenAt=" + caveatResponse.getWrittenAt() + " ==="); + permissionsStub.writeRelationships(request); } private static RelationshipUpdate buildUpdate(String resourceType, String resourceId, @@ -250,79 +247,6 @@ static String encode(String value) { .encodeToString(value.getBytes(StandardCharsets.UTF_8)); } - /** - * Performs a direct CheckPermission call for diagnostics, bypassing the SDK. - */ - public String directCheckPermission(String subjectType, String subjectId, - String resourceType, String resourceId, - String permission, String atTimestamp) { - com.authzed.api.v1.CheckPermissionRequest.Builder reqBuilder = - com.authzed.api.v1.CheckPermissionRequest.newBuilder() - .setConsistency(com.authzed.api.v1.Consistency.newBuilder() - .setFullyConsistent(true) - .build()) - .setSubject(com.authzed.api.v1.SubjectReference.newBuilder() - .setObject(ObjectReference.newBuilder() - .setObjectType(subjectType) - .setObjectId(encode(subjectId)) - .build()) - .build()) - .setResource(ObjectReference.newBuilder() - .setObjectType(resourceType) - .setObjectId(encode(resourceId)) - .build()) - .setPermission(permission); - - if (atTimestamp != null) { - reqBuilder.setContext(com.google.protobuf.Struct.newBuilder() - .putFields("at", com.google.protobuf.Value.newBuilder() - .setStringValue(atTimestamp) - .build()) - .build()); - } - - var resp = permissionsStub.checkPermission(reqBuilder.build()); - return resp.getPermissionship().name(); - } - - /** - * Reads back the current schema from SpiceDB (for diagnostics). - * - * @return the schema text currently stored in SpiceDB - */ - public String readSchema() { - var response = schemaStub.readSchema( - com.authzed.api.v1.ReadSchemaRequest.newBuilder().build()); - return response.getSchemaText(); - } - - /** - * Reads back all relationships for a given resource type (for diagnostics). - * - * @return list of relationship strings - */ - public java.util.List readRelationships(String resourceType) { - var response = permissionsStub.readRelationships( - com.authzed.api.v1.ReadRelationshipsRequest.newBuilder() - .setConsistency(com.authzed.api.v1.Consistency.newBuilder() - .setFullyConsistent(true) - .build()) - .setRelationshipFilter(com.authzed.api.v1.RelationshipFilter.newBuilder() - .setResourceType(resourceType) - .build()) - .build()); - java.util.List results = new java.util.ArrayList<>(); - while (response.hasNext()) { - var r = response.next().getRelationship(); - results.add(r.getResource().getObjectType() + ":" + r.getResource().getObjectId() - + "#" + r.getRelation() - + "@" + r.getSubject().getObject().getObjectType() + ":" + r.getSubject().getObject().getObjectId() - + (r.hasOptionalCaveat() && !r.getOptionalCaveat().getCaveatName().isEmpty() - ? "[" + r.getOptionalCaveat().getCaveatName() + "]" : "")); - } - return results; - } - /** * Shuts down the underlying gRPC channel. Call in {@code @AfterAll}. */ From 9167a92fe2f16841573aa3c4eb7117ce66e43bb4 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 18:49:45 +0200 Subject: [PATCH 27/28] feat: add configurable consistency policy for SpiceDB reads Introduce `ConsistencyPolicy` enum (MINIMIZE_LATENCY, FULLY_CONSISTENT) and wire it through ClientConfiguration. Use MINIMIZE_LATENCY by default for best performance; E2E tests override to FULLY_CONSISTENT. Treat CONDITIONAL_PERMISSION as denied (fail-closed) in FGA and bulk checks, with warning logs when encountered. --- README.md | 8 +++ docs/architecture.md | 50 +++++++++++++++---- docs/architecture/coding-standards.md | 3 +- docs/architecture/source-tree.md | 8 ++- docs/architecture/tech-stack.md | 4 +- .../spring/EntitlementsAutoConfiguration.java | 5 +- .../spring/EntitlementsProperties.java | 5 ++ pom.xml | 4 +- .../config/ClientConfiguration.java | 34 +++++++++++++ .../config/ConsistencyPolicy.java | 28 +++++++++++ .../internal/ConsistencyFactory.java | 38 ++++++++++++++ .../internal/FeatureSpiceDBQuery.java | 22 ++++++-- .../internal/FgaSpiceDBQuery.java | 24 ++++++--- .../internal/LookupSpiceDBQuery.java | 10 ++-- .../internal/PermissionSpiceDBQuery.java | 22 ++++++-- .../internal/RouteSpiceDBQuery.java | 22 ++++++-- .../internal/SpiceDBQueryClient.java | 17 ++++--- .../sdk/entitlements/e2e/SpiceDBE2ETest.java | 2 + .../integration/SpiceDBIntegrationTest.java | 3 ++ .../internal/FeatureSpiceDBQueryTest.java | 25 ++++++---- .../internal/FgaSpiceDBQueryTest.java | 25 ++++++---- .../internal/LookupSpiceDBQueryTest.java | 41 ++++++++------- .../internal/PermissionSpiceDBQueryTest.java | 35 +++++++------ .../internal/RouteSpiceDBQueryTest.java | 27 +++++----- .../SpiceDBEntitlementsClientTest.java | 9 ++-- .../internal/SpiceDBQueryClientTest.java | 11 ++-- 26 files changed, 362 insertions(+), 120 deletions(-) create mode 100644 src/main/java/com/frontegg/sdk/entitlements/config/ConsistencyPolicy.java create mode 100644 src/main/java/com/frontegg/sdk/entitlements/internal/ConsistencyFactory.java diff --git a/README.md b/README.md index 3210acd..3305e1a 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ fields are validated in `build()`. ```java import com.frontegg.sdk.entitlements.config.ClientConfiguration; import com.frontegg.sdk.entitlements.config.CacheConfiguration; +import com.frontegg.sdk.entitlements.config.ConsistencyPolicy; import com.frontegg.sdk.entitlements.fallback.StaticFallback; import java.time.Duration; @@ -219,6 +220,10 @@ ClientConfiguration config = ClientConfiguration.builder() // Optional: enable monitoring mode (see Monitoring Mode section). Default: false. .monitoring(false) + // Optional: SpiceDB read consistency. Default: MINIMIZE_LATENCY. + // Use FULLY_CONSISTENT when read-after-write consistency is required. + .consistencyPolicy(ConsistencyPolicy.MINIMIZE_LATENCY) + // Optional: enable in-memory result caching (see Caching section). Default: disabled. .cacheConfiguration(CacheConfiguration.defaults()) @@ -237,6 +242,7 @@ ClientConfiguration config = ClientConfiguration.builder() | `maxRetries` | `int` | `3` | No | Maximum retry attempts with exponential backoff before the fallback is invoked or the exception propagates. | | `useTls` | `boolean` | `true` | No | Whether to use TLS on the gRPC channel. Disable only for local development. | | `monitoring` | `boolean` | `false` | No | When `true`, checks are evaluated and logged but always return `allowed`. See [Monitoring Mode](#monitoring-mode). | +| `consistencyPolicy` | `ConsistencyPolicy` | `MINIMIZE_LATENCY` | No | SpiceDB read consistency: `MINIMIZE_LATENCY` (fastest, allows stale reads) or `FULLY_CONSISTENT` (linearizable). | | `cacheConfiguration` | `CacheConfiguration` | `null` | No | When set, results are cached in memory. `null` disables caching. See [Caching](#caching). | **Credential rotation** is supported by providing a `Supplier` for the token. The @@ -853,6 +859,7 @@ frontegg.entitlements.request-timeout=5s frontegg.entitlements.bulk-request-timeout=15s frontegg.entitlements.max-retries=3 frontegg.entitlements.monitoring=false +frontegg.entitlements.consistency-policy=minimize_latency # Optional — static fallback result when the engine is unreachable. # true = fail-open, false = fail-closed. Omit to propagate exceptions instead. @@ -878,6 +885,7 @@ frontegg: bulk-request-timeout: 15s max-retries: 3 monitoring: false + consistency-policy: minimize_latency fallback-result: false cache: max-size: 10000 diff --git a/docs/architecture.md b/docs/architecture.md index 6900d22..5259cb9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,10 +83,10 @@ N/A — This is a client library published to Maven Central. No cloud infrastruc | **gRPC** | grpc-java | 1.78.0 | gRPC transport (via authzed-java) | Transitive dep from authzed-java, uses shaded Netty | | **Protobuf** | protobuf-java | 4.33.5 | Protocol Buffers (via authzed-java) | Transitive dep from authzed-java | | **Logging** | SLF4J API | 2.0.x | Logging facade | Universal Java logging facade, no implementation forced | -| **Caching** | Caffeine | 3.1.x | In-memory caching | High-performance, thread-safe, Java 11+ compatible | +| **Caching** | Caffeine | 3.2.x | In-memory caching | High-performance, thread-safe, Java 11+ compatible | | **Spring Integration** | Spring Boot (provided) | 3.2+ | Auto-configuration for Spring starter module | Provided scope — no forced Spring dependency for non-Spring consumers | | **Testing** | JUnit 5 | 5.10.x | Test framework | Industry standard, parameterized tests, extensions | -| **Testing** | Mockito | 5.x | Mocking library | De facto standard for Java mocking | +| **Testing** | Mockito | 5.22.x | Mocking library | De facto standard for Java mocking | | **Testing** | SLF4J Simple | 2.0.x | Test logging impl | Lightweight, test-scope only | | **Publishing** | central-publishing-maven-plugin | 0.10.0 | Maven Central publishing | Sonatype Central Portal (replaces deprecated nexus-staging) | | **Signing** | maven-gpg-plugin | 3.2.7 | Artifact signing | Required for Maven Central | @@ -151,6 +151,15 @@ This SDK does not own data — it constructs gRPC requests and maps responses. T - `bulkRequestTimeout: Duration` — Bulk check deadline (default: 15s) - `maxRetries: int` — Retry count for transient errors (default: 3) - `useTls: boolean` — Enable TLS (default: true) +- `consistencyPolicy: ConsistencyPolicy` — SpiceDB read consistency (default: `MINIMIZE_LATENCY`) + +### ConsistencyPolicy (enum) + +**Purpose:** Controls the consistency guarantee for SpiceDB reads. + +**Values:** +- `MINIMIZE_LATENCY` — SpiceDB's default; fastest, allows stale reads +- `FULLY_CONSISTENT` — Linearizable reads; hits the primary datastore on every request. Use for read-after-write consistency (e.g. tests, post-relationship-write flows) --- @@ -183,7 +192,7 @@ This SDK does not own data — it constructs gRPC requests and maps responses. T **Key Interfaces:** - `EntitlementsResult execute(SubjectContext, RequestContext)` -**Dependencies:** `FeatureSpiceDBQuery`, `PermissionSpiceDBQuery`, `FgaSpiceDBQuery`, authzed-java stubs +**Dependencies:** `FeatureSpiceDBQuery`, `PermissionSpiceDBQuery`, `FgaSpiceDBQuery`, `RouteSpiceDBQuery`, `LookupSpiceDBQuery`, `ConsistencyFactory`, authzed-java stubs ### Query Strategies (internal) @@ -196,7 +205,18 @@ This SDK does not own data — it constructs gRPC requests and maps responses. T - `RouteSpiceDBQuery` — Route matching with cached relationships - `LookupSpiceDBQuery` — Dispatches `LookupResources` and `LookupSubjects` RPCs -**Dependencies:** authzed-java gRPC stubs, `Base64Utils`, `CaveatContextBuilder` +**Dependencies:** authzed-java gRPC stubs, `Base64Utils`, `CaveatContextBuilder`, `Supplier` + +**Fail-closed behavior:** All query strategies treat `PERMISSIONSHIP_CONDITIONAL_PERMISSION` as denied (fail-closed) and emit a `WARN`-level log. This prevents incomplete caveat context from granting unintended access. + +### ConsistencyFactory (internal) + +**Responsibility:** Converts a `ConsistencyPolicy` enum into a `Supplier` that returns a pre-built protobuf `Consistency` instance. Zero allocation per call. + +**Key Interfaces:** +- `static Supplier supplierFor(ConsistencyPolicy policy)` + +**Dependencies:** `ConsistencyPolicy`, authzed-java `Consistency` protobuf ### RetryHandler (internal) @@ -233,6 +253,7 @@ graph TB B64[Base64Utils] CVT[CaveatContextBuilder] CCH[CaffeineCacheProvider] + CF[ConsistencyFactory] end subgraph "External (authzed-java)" @@ -241,6 +262,7 @@ graph TB ECF --> SDBC SDBC --> QC + QC --> CF QC --> FQ QC --> PQ QC --> EQ @@ -415,7 +437,7 @@ tag → Publish workflow → Maven Central staging → validation → release - **Format:** Structured key-value pairs in message: `"Checking entitlement subject={} resource={} relation={}"` - **Levels:** - `ERROR`: Unrecoverable failures (configuration errors, fallback failures) - - `WARN`: Fallback activated, retry exhausted, forced channel shutdown + - `WARN`: Fallback activated, retry exhausted, forced channel shutdown, `CONDITIONAL_PERMISSION` treated as denied - `INFO`: Monitoring mode results, client creation/close - `DEBUG`: Individual check inputs/outputs, cache hits/misses - `TRACE`: Raw gRPC request/response (with token redacted) @@ -501,12 +523,22 @@ tag → Publish workflow → Maven Central staging → validation → release #### Integration Tests -- **Scope:** End-to-end tests against real SpiceDB instance +- **Scope:** Tests against a real SpiceDB instance via Testcontainers - **Location:** `src/test/java/com/frontegg/sdk/entitlements/integration/` +- **Maven Profile:** `integration` (`mvn verify -Pintegration`) +- **Consistency:** Uses `FULLY_CONSISTENT` to ensure deterministic results - **Test Infrastructure:** - **SpiceDB:** Testcontainers with `authzed/spicedb` Docker image - **Schema:** Test SpiceDB schema loaded via test fixtures +#### E2E Tests + +- **Scope:** Full end-to-end tests against a real SpiceDB instance covering all query types +- **Location:** `src/test/java/com/frontegg/sdk/entitlements/e2e/` +- **Maven Profile:** `e2e` (`mvn verify -Pe2e`) +- **Consistency:** Uses `FULLY_CONSISTENT` to ensure deterministic results +- **Coverage:** Feature, Permission, Route, FGA, and Lookup operations + #### Cross-Language Compatibility Tests - **Scope:** Verify Base64 encoding output matches TypeScript SDK for a known set of inputs @@ -597,6 +629,6 @@ Rate limiting and DoS protection are the responsibility of the SpiceDB deploymen ## Next Steps -1. Add integration tests using Testcontainers against a real SpiceDB instance -2. Evaluate optional Micrometer companion module for metrics instrumentation -3. Consider Checkstyle or Spotless for automated style enforcement in CI +1. Evaluate optional Micrometer companion module for metrics instrumentation +2. Consider Checkstyle or Spotless for automated style enforcement in CI +3. Add `AT_LEAST_AS_FRESH` consistency policy with ZedToken support diff --git a/docs/architecture/coding-standards.md b/docs/architecture/coding-standards.md index 5573db9..3c12f9f 100644 --- a/docs/architecture/coding-standards.md +++ b/docs/architecture/coding-standards.md @@ -33,6 +33,7 @@ - **Use `Optional` sparingly:** Only for truly optional return values. Never as method parameters. - **Prefer `Supplier` for tokens:** Enables credential rotation without client reinstantiation. - **Unchecked exceptions only:** No checked exceptions in the public API. +- **Fail-closed on ambiguous permissions:** `PERMISSIONSHIP_CONDITIONAL_PERMISSION` from SpiceDB must be treated as denied. Log at `WARN` level with subject/resource context so operators can investigate incomplete caveat context. ## Error Handling @@ -52,7 +53,7 @@ - **Library:** SLF4J 2.0.x - **Levels:** - `ERROR`: Unrecoverable failures - - `WARN`: Fallback activated, retry exhausted, forced shutdown + - `WARN`: Fallback activated, retry exhausted, forced shutdown, `CONDITIONAL_PERMISSION` treated as denied (fail-closed) - `INFO`: Monitoring mode results, client creation/close - `DEBUG`: Check inputs/outputs, cache hits/misses - `TRACE`: Raw gRPC request/response (token redacted) diff --git a/docs/architecture/source-tree.md b/docs/architecture/source-tree.md index 1d7ca7f..830e06d 100644 --- a/docs/architecture/source-tree.md +++ b/docs/architecture/source-tree.md @@ -51,7 +51,8 @@ entitlements-client-java/ │ │ └── LookupResult.java # Record │ ├── config/ │ │ ├── ClientConfiguration.java # Builder pattern - │ │ └── CacheConfiguration.java # Record + │ │ ├── CacheConfiguration.java # Record + │ │ └── ConsistencyPolicy.java # Enum (MINIMIZE_LATENCY, FULLY_CONSISTENT) │ ├── fallback/ │ │ ├── FallbackStrategy.java # Sealed interface │ │ ├── StaticFallback.java # Record @@ -75,6 +76,7 @@ entitlements-client-java/ │ ├── BearerTokenCallCredentials.java # gRPC call credentials │ ├── CaveatContextBuilder.java # Builds protobuf Struct │ ├── EntitlementsCacheKey.java # Cache key record + │ ├── ConsistencyFactory.java # Converts ConsistencyPolicy → Supplier │ ├── BulkPermissionsExecutor.java # Executes CheckBulkPermissions RPCs │ ├── CheckPermissionExecutor.java # Executes CheckPermission RPCs │ ├── LookupResourcesExecutor.java # Executes LookupResources RPCs @@ -86,6 +88,10 @@ entitlements-client-java/ │ └── LookupSpiceDBQuery.java # LookupResources / LookupSubjects dispatch └── test/java/com/frontegg/sdk/entitlements/ ├── EntitlementsClientFactoryTest.java + ├── e2e/ + │ └── SpiceDBE2ETest.java # Full E2E tests (mvn verify -Pe2e) + ├── integration/ + │ └── SpiceDBIntegrationTest.java # Integration tests (mvn verify -Pintegration) ├── cache/ │ └── CaffeineCacheProviderTest.java ├── config/ diff --git a/docs/architecture/tech-stack.md b/docs/architecture/tech-stack.md index 6edc13a..8a200e6 100644 --- a/docs/architecture/tech-stack.md +++ b/docs/architecture/tech-stack.md @@ -8,10 +8,10 @@ | **gRPC** | grpc-java | 1.78.0 | gRPC transport (via authzed-java) | | **Protobuf** | protobuf-java | 4.33.5 | Protocol Buffers (via authzed-java) | | **Logging** | SLF4J API | 2.0.x | Logging facade | -| **Caching** | Caffeine | 3.1.x | In-memory caching | +| **Caching** | Caffeine | 3.2.x | In-memory caching | | **Spring Integration** | Spring Boot (provided) | 3.2+ | Auto-configuration for the starter module | | **Testing** | JUnit 5 | 5.10.x | Test framework | -| **Testing** | Mockito | 5.x | Mocking library | +| **Testing** | Mockito | 5.22.x | Mocking library | | **Publishing** | central-publishing-maven-plugin | 0.10.0 | Maven Central publishing | | **Signing** | maven-gpg-plugin | 3.2.7 | Artifact signing | | **CI/CD** | GitHub Actions | N/A | CI and deployment | diff --git a/entitlements-client-spring-boot-starter/src/main/java/com/frontegg/sdk/entitlements/spring/EntitlementsAutoConfiguration.java b/entitlements-client-spring-boot-starter/src/main/java/com/frontegg/sdk/entitlements/spring/EntitlementsAutoConfiguration.java index b9af8fc..36c2a04 100644 --- a/entitlements-client-spring-boot-starter/src/main/java/com/frontegg/sdk/entitlements/spring/EntitlementsAutoConfiguration.java +++ b/entitlements-client-spring-boot-starter/src/main/java/com/frontegg/sdk/entitlements/spring/EntitlementsAutoConfiguration.java @@ -4,6 +4,7 @@ import com.frontegg.sdk.entitlements.EntitlementsClientFactory; import com.frontegg.sdk.entitlements.config.CacheConfiguration; import com.frontegg.sdk.entitlements.config.ClientConfiguration; +import com.frontegg.sdk.entitlements.config.ConsistencyPolicy; import com.frontegg.sdk.entitlements.fallback.StaticFallback; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -48,7 +49,9 @@ public EntitlementsClient entitlementsClient(EntitlementsProperties properties) .requestTimeout(properties.getRequestTimeout()) .bulkRequestTimeout(properties.getBulkRequestTimeout()) .maxRetries(properties.getMaxRetries()) - .monitoring(properties.isMonitoring()); + .monitoring(properties.isMonitoring()) + .consistencyPolicy(ConsistencyPolicy.valueOf( + properties.getConsistencyPolicy().toUpperCase())); if (properties.getFallbackResult() != null) { builder.fallbackStrategy(new StaticFallback(properties.getFallbackResult())); diff --git a/entitlements-client-spring-boot-starter/src/main/java/com/frontegg/sdk/entitlements/spring/EntitlementsProperties.java b/entitlements-client-spring-boot-starter/src/main/java/com/frontegg/sdk/entitlements/spring/EntitlementsProperties.java index b57892d..8170d65 100644 --- a/entitlements-client-spring-boot-starter/src/main/java/com/frontegg/sdk/entitlements/spring/EntitlementsProperties.java +++ b/entitlements-client-spring-boot-starter/src/main/java/com/frontegg/sdk/entitlements/spring/EntitlementsProperties.java @@ -19,6 +19,7 @@ * frontegg.entitlements.fallback-result=false * frontegg.entitlements.cache.max-size=10000 * frontegg.entitlements.cache.expire-after-write=60s + * frontegg.entitlements.consistency-policy=minimize_latency * * * @since 0.2.0 @@ -36,6 +37,7 @@ public class EntitlementsProperties { private Boolean fallbackResult; // null = no fallback, true/false = static fallback private boolean enabled = true; private CacheProperties cache; + private String consistencyPolicy = "minimize_latency"; public String getEngineEndpoint() { return engineEndpoint; } public void setEngineEndpoint(String engineEndpoint) { this.engineEndpoint = engineEndpoint; } @@ -67,6 +69,9 @@ public class EntitlementsProperties { public CacheProperties getCache() { return cache; } public void setCache(CacheProperties cache) { this.cache = cache; } + public String getConsistencyPolicy() { return consistencyPolicy; } + public void setConsistencyPolicy(String consistencyPolicy) { this.consistencyPolicy = consistencyPolicy; } + /** * Nested cache configuration properties. */ diff --git a/pom.xml b/pom.xml index 7ff2a01..7a4a32b 100644 --- a/pom.xml +++ b/pom.xml @@ -278,7 +278,7 @@ org.apache.maven.plugins maven-failsafe-plugin - 3.2.5 + ${maven-surefire-plugin.version} **/integration/**Test.java @@ -303,7 +303,7 @@ org.apache.maven.plugins maven-failsafe-plugin - 3.5.5 + ${maven-surefire-plugin.version} **/e2e/**Test.java diff --git a/src/main/java/com/frontegg/sdk/entitlements/config/ClientConfiguration.java b/src/main/java/com/frontegg/sdk/entitlements/config/ClientConfiguration.java index 3d2b302..082bfac 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/config/ClientConfiguration.java +++ b/src/main/java/com/frontegg/sdk/entitlements/config/ClientConfiguration.java @@ -40,6 +40,9 @@ public final class ClientConfiguration { /** Default maximum number of retry attempts for transient failures. */ public static final int DEFAULT_MAX_RETRIES = 3; + /** Default consistency policy for SpiceDB reads. */ + public static final ConsistencyPolicy DEFAULT_CONSISTENCY_POLICY = ConsistencyPolicy.MINIMIZE_LATENCY; + private final String engineEndpoint; private final Supplier engineToken; private final FallbackStrategy fallbackStrategy; @@ -49,6 +52,7 @@ public final class ClientConfiguration { private final boolean useTls; private final boolean monitoring; private final CacheConfiguration cacheConfiguration; + private final ConsistencyPolicy consistencyPolicy; private ClientConfiguration(Builder builder) { this.engineEndpoint = builder.engineEndpoint; @@ -60,6 +64,7 @@ private ClientConfiguration(Builder builder) { this.useTls = builder.useTls; this.monitoring = builder.monitoring; this.cacheConfiguration = builder.cacheConfiguration; + this.consistencyPolicy = builder.consistencyPolicy; } /** @@ -175,6 +180,16 @@ public CacheConfiguration getCacheConfiguration() { return cacheConfiguration; } + /** + * Returns the consistency policy for SpiceDB reads. + * + * @return the consistency policy; never {@code null} + * @since 0.2.0 + */ + public ConsistencyPolicy getConsistencyPolicy() { + return consistencyPolicy; + } + /** * Returns a string representation of this configuration with the engine token redacted to * prevent accidental secret exposure in logs or stack traces. @@ -194,6 +209,7 @@ public String toString() { + ", useTls=" + useTls + ", monitoring=" + monitoring + ", cacheConfiguration=" + cacheConfiguration + + ", consistencyPolicy=" + consistencyPolicy + '}'; } @@ -220,6 +236,7 @@ public static final class Builder { private boolean useTls = true; private boolean monitoring = false; private CacheConfiguration cacheConfiguration; // null = caching disabled + private ConsistencyPolicy consistencyPolicy = DEFAULT_CONSISTENCY_POLICY; private Builder() { } @@ -379,6 +396,23 @@ public Builder cacheConfiguration(CacheConfiguration cacheConfiguration) { return this; } + /** + * Sets the consistency policy for SpiceDB reads. + * + *

Default is {@link ConsistencyPolicy#MINIMIZE_LATENCY} (SpiceDB's default, best + * performance). Use {@link ConsistencyPolicy#FULLY_CONSISTENT} when you need + * read-after-write consistency. + * + * @param consistencyPolicy the consistency policy; must not be {@code null} + * @return this builder + * @since 0.2.0 + */ + public Builder consistencyPolicy(ConsistencyPolicy consistencyPolicy) { + this.consistencyPolicy = Objects.requireNonNull(consistencyPolicy, + "consistencyPolicy must not be null"); + return this; + } + /** * Validates the configuration and builds the {@link ClientConfiguration} instance. * diff --git a/src/main/java/com/frontegg/sdk/entitlements/config/ConsistencyPolicy.java b/src/main/java/com/frontegg/sdk/entitlements/config/ConsistencyPolicy.java new file mode 100644 index 0000000..c985db9 --- /dev/null +++ b/src/main/java/com/frontegg/sdk/entitlements/config/ConsistencyPolicy.java @@ -0,0 +1,28 @@ +package com.frontegg.sdk.entitlements.config; + +/** + * Controls the consistency guarantee for SpiceDB reads. + * + *

    + *
  • {@link #MINIMIZE_LATENCY} — SpiceDB's default; fastest, allows stale reads.
  • + *
  • {@link #FULLY_CONSISTENT} — linearizable reads; hits the primary datastore on every + * request. Use when you need read-after-write consistency (e.g. in tests or after + * relationship writes).
  • + *
+ * + * @since 0.2.0 + */ +public enum ConsistencyPolicy { + + /** + * SpiceDB's default consistency mode. Allows the engine to serve reads from replicas, + * providing the lowest latency at the cost of potentially stale data. + */ + MINIMIZE_LATENCY, + + /** + * Linearizable consistency. Every read goes to the primary datastore, guaranteeing + * the most up-to-date result. Higher latency than {@link #MINIMIZE_LATENCY}. + */ + FULLY_CONSISTENT +} diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/ConsistencyFactory.java b/src/main/java/com/frontegg/sdk/entitlements/internal/ConsistencyFactory.java new file mode 100644 index 0000000..620d249 --- /dev/null +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/ConsistencyFactory.java @@ -0,0 +1,38 @@ +package com.frontegg.sdk.entitlements.internal; + +import com.authzed.api.v1.Consistency; +import com.frontegg.sdk.entitlements.config.ConsistencyPolicy; + +import java.util.function.Supplier; + +/** + * Package-private helper that converts a {@link ConsistencyPolicy} enum value into a + * protobuf {@link Consistency} supplier. + * + *

For {@link ConsistencyPolicy#MINIMIZE_LATENCY} and {@link ConsistencyPolicy#FULLY_CONSISTENT} + * the returned supplier always returns the same pre-built instance (zero allocation per call). + */ +final class ConsistencyFactory { + + private static final Consistency MINIMIZE_LATENCY = + Consistency.newBuilder().setMinimizeLatency(true).build(); + + private static final Consistency FULLY_CONSISTENT = + Consistency.newBuilder().setFullyConsistent(true).build(); + + private ConsistencyFactory() { + } + + /** + * Returns a supplier that produces the {@link Consistency} protobuf for the given policy. + * + * @param policy the consistency policy; must not be {@code null} + * @return a non-null supplier; the returned {@code Consistency} object is safe to reuse + */ + static Supplier supplierFor(ConsistencyPolicy policy) { + return switch (policy) { + case MINIMIZE_LATENCY -> () -> MINIMIZE_LATENCY; + case FULLY_CONSISTENT -> () -> FULLY_CONSISTENT; + }; + } +} diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java index 0726cf5..cf7bff6 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQuery.java @@ -16,6 +16,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.function.Supplier; + /** * Package-private query strategy that handles {@link FeatureRequestContext} checks. * @@ -43,9 +45,11 @@ class FeatureSpiceDBQuery { private static final String TYPE_FEATURE = "frontegg_feature"; private final BulkPermissionsExecutor executor; + private final Supplier consistencySupplier; - FeatureSpiceDBQuery(BulkPermissionsExecutor executor) { + FeatureSpiceDBQuery(BulkPermissionsExecutor executor, Supplier consistencySupplier) { this.executor = executor; + this.consistencySupplier = consistencySupplier; } /** @@ -77,7 +81,7 @@ EntitlementsResult query(UserSubjectContext userCtx, FeatureRequestContext featu TYPE_TENANT, b64TenantId, featureResource, caveatContext); CheckBulkPermissionsRequest request = CheckBulkPermissionsRequest.newBuilder() - .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()) + .setConsistency(consistencySupplier.get()) .addItems(userItem) .addItems(tenantItem) .build(); @@ -91,11 +95,21 @@ EntitlementsResult query(UserSubjectContext userCtx, FeatureRequestContext featu } } + boolean hasConditional = response.getPairsList().stream() + .filter(CheckBulkPermissionsPair::hasItem) + .map(pair -> pair.getItem().getPermissionship()) + .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); + if (hasConditional) { + log.warn("SpiceDB returned CONDITIONAL_PERMISSION for feature check " + + "userId={} featureKey={} — treating as denied (fail-closed). " + + "Ensure caveat context is fully populated.", + userCtx.userId(), featureCtx.featureKey()); + } + boolean entitled = response.getPairsList().stream() .filter(CheckBulkPermissionsPair::hasItem) .map(pair -> pair.getItem().getPermissionship()) - .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION - || p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); + .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION); log.debug("Feature check result entitled={} userId={} featureKey={}", entitled, userCtx.userId(), featureCtx.featureKey()); diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java index faad3aa..bee997a 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java @@ -12,6 +12,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.function.Supplier; + /** * Package-private query strategy that handles fine-grained authorization (FGA) checks * for {@link EntityRequestContext} requests. @@ -35,9 +37,11 @@ class FgaSpiceDBQuery { private static final Logger log = LoggerFactory.getLogger(FgaSpiceDBQuery.class); private final CheckPermissionExecutor executor; + private final Supplier consistencySupplier; - FgaSpiceDBQuery(CheckPermissionExecutor executor) { + FgaSpiceDBQuery(CheckPermissionExecutor executor, Supplier consistencySupplier) { this.executor = executor; + this.consistencySupplier = consistencySupplier; } /** @@ -72,9 +76,7 @@ EntitlementsResult query(EntitySubjectContext entityCtx, EntityRequestContext re Struct caveatContext = CaveatContextBuilder.build(null, requestCtx.at()); CheckPermissionRequest.Builder requestBuilder = CheckPermissionRequest.newBuilder() - .setConsistency(Consistency.newBuilder() - .setFullyConsistent(true) - .build()) + .setConsistency(consistencySupplier.get()) .setSubject(subject) .setResource(resource) .setPermission(requestCtx.relation()); @@ -87,10 +89,18 @@ EntitlementsResult query(EntitySubjectContext entityCtx, EntityRequestContext re CheckPermissionResponse response = executor.execute(request); + if (response.getPermissionship() + == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION) { + log.warn("SpiceDB returned CONDITIONAL_PERMISSION for FGA check " + + "entityType={} entityId={} resourceType={} resourceId={} relation={} " + + "— treating as denied (fail-closed). Ensure caveat context is fully populated.", + entityCtx.entityType(), entityCtx.entityId(), + requestCtx.resourceType(), requestCtx.resourceId(), + requestCtx.relation()); + } + boolean allowed = response.getPermissionship() - == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION - || response.getPermissionship() - == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION; + == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION; log.debug("FGA check result allowed={} entityType={} entityId={} resourceType={} resourceId={} relation={}", allowed, diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java index e00d46b..28ee297 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQuery.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.function.Supplier; /** * Package-private query strategy that handles SpiceDB {@code LookupResources} and @@ -34,11 +35,14 @@ class LookupSpiceDBQuery { private final LookupResourcesExecutor lookupResourcesExecutor; private final LookupSubjectsExecutor lookupSubjectsExecutor; + private final Supplier consistencySupplier; LookupSpiceDBQuery(LookupResourcesExecutor lookupResourcesExecutor, - LookupSubjectsExecutor lookupSubjectsExecutor) { + LookupSubjectsExecutor lookupSubjectsExecutor, + Supplier consistencySupplier) { this.lookupResourcesExecutor = lookupResourcesExecutor; this.lookupSubjectsExecutor = lookupSubjectsExecutor; + this.consistencySupplier = consistencySupplier; } /** @@ -58,7 +62,7 @@ LookupResult lookupResources(LookupResourcesRequest request) { com.authzed.api.v1.LookupResourcesRequest.Builder grpcRequestBuilder = com.authzed.api.v1.LookupResourcesRequest.newBuilder() - .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()) + .setConsistency(consistencySupplier.get()) .setSubject(SubjectReference.newBuilder() .setObject(ObjectReference.newBuilder() .setObjectType(request.subjectType()) @@ -106,7 +110,7 @@ LookupResult lookupSubjects(LookupSubjectsRequest request) { com.authzed.api.v1.LookupSubjectsRequest.Builder grpcRequestBuilder = com.authzed.api.v1.LookupSubjectsRequest.newBuilder() - .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()) + .setConsistency(consistencySupplier.get()) .setResource(ObjectReference.newBuilder() .setObjectType(request.resourceType()) .setObjectId(b64ResourceId) diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java index d66c2b9..11d66fa 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQuery.java @@ -17,6 +17,7 @@ import org.slf4j.LoggerFactory; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -50,9 +51,11 @@ class PermissionSpiceDBQuery { private static final String TYPE_PERMISSION = "frontegg_permission"; private final BulkPermissionsExecutor executor; + private final Supplier consistencySupplier; - PermissionSpiceDBQuery(BulkPermissionsExecutor executor) { + PermissionSpiceDBQuery(BulkPermissionsExecutor executor, Supplier consistencySupplier) { this.executor = executor; + this.consistencySupplier = consistencySupplier; } /** @@ -76,7 +79,7 @@ EntitlementsResult query(UserSubjectContext userCtx, PermissionRequestContext pe Struct caveatContext = CaveatContextBuilder.build(userCtx.attributes(), permissionCtx.at()); CheckBulkPermissionsRequest.Builder requestBuilder = CheckBulkPermissionsRequest.newBuilder() - .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()); + .setConsistency(consistencySupplier.get()); for (String permissionKey : permissionCtx.permissionKeys()) { String b64PermissionKey = Base64Utils.encode(permissionKey); @@ -99,13 +102,22 @@ EntitlementsResult query(UserSubjectContext userCtx, PermissionRequestContext pe } } + boolean hasConditional = response.getPairsList().stream() + .filter(CheckBulkPermissionsPair::hasItem) + .anyMatch(pair -> pair.getItem().getPermissionship() + == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); + if (hasConditional) { + log.warn("SpiceDB returned CONDITIONAL_PERMISSION for permission check " + + "userId={} permissionKeys={} — treating as denied (fail-closed). " + + "Ensure caveat context is fully populated.", + userCtx.userId(), permissionCtx.permissionKeys()); + } + // Collect all resource IDs (base64 permission keys) that have PERMISSIONSHIP_HAS_PERMISSION Set entitledPermissionIds = response.getPairsList().stream() .filter(CheckBulkPermissionsPair::hasItem) .filter(pair -> pair.getItem().getPermissionship() - == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION - || pair.getItem().getPermissionship() - == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION) + == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION) .map(pair -> pair.getRequest().getResource().getObjectId()) .collect(Collectors.toSet()); diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java b/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java index 977decc..82b2314 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQuery.java @@ -16,6 +16,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.function.Supplier; + /** * Package-private query strategy that handles {@link RouteRequestContext} checks. * @@ -46,9 +48,11 @@ class RouteSpiceDBQuery { private static final String TYPE_ROUTE = "frontegg_route"; private final BulkPermissionsExecutor executor; + private final Supplier consistencySupplier; - RouteSpiceDBQuery(BulkPermissionsExecutor executor) { + RouteSpiceDBQuery(BulkPermissionsExecutor executor, Supplier consistencySupplier) { this.executor = executor; + this.consistencySupplier = consistencySupplier; } /** @@ -82,7 +86,7 @@ EntitlementsResult query(UserSubjectContext userCtx, RouteRequestContext routeCt TYPE_TENANT, b64TenantId, routeResource, caveatContext); CheckBulkPermissionsRequest request = CheckBulkPermissionsRequest.newBuilder() - .setConsistency(Consistency.newBuilder().setFullyConsistent(true).build()) + .setConsistency(consistencySupplier.get()) .addItems(userItem) .addItems(tenantItem) .build(); @@ -97,11 +101,21 @@ EntitlementsResult query(UserSubjectContext userCtx, RouteRequestContext routeCt } } + boolean hasConditional = response.getPairsList().stream() + .filter(CheckBulkPermissionsPair::hasItem) + .map(pair -> pair.getItem().getPermissionship()) + .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); + if (hasConditional) { + log.warn("SpiceDB returned CONDITIONAL_PERMISSION for route check " + + "userId={} method={} path={} — treating as denied (fail-closed). " + + "Ensure caveat context is fully populated.", + userCtx.userId(), routeCtx.method(), routeCtx.path()); + } + boolean entitled = response.getPairsList().stream() .filter(CheckBulkPermissionsPair::hasItem) .map(pair -> pair.getItem().getPermissionship()) - .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION - || p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); + .anyMatch(p -> p == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION); log.debug("Route check result entitled={} userId={} method={} path={}", entitled, userCtx.userId(), routeCtx.method(), routeCtx.path()); diff --git a/src/main/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClient.java b/src/main/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClient.java index d7d03c5..e097775 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClient.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClient.java @@ -1,5 +1,6 @@ package com.frontegg.sdk.entitlements.internal; +import com.authzed.api.v1.Consistency; import com.authzed.api.v1.PermissionsServiceGrpc; import com.frontegg.sdk.entitlements.config.ClientConfiguration; import com.frontegg.sdk.entitlements.model.EntitlementsResult; @@ -19,6 +20,7 @@ import org.slf4j.LoggerFactory; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; /** * Package-private strategy dispatcher that routes an entitlement check to the correct @@ -68,11 +70,13 @@ class SpiceDBQueryClient { config.getBulkRequestTimeout().toMillis(), TimeUnit.MILLISECONDS)) .lookupSubjects(request); - this.featureQuery = new FeatureSpiceDBQuery(bulkExecutor); - this.permissionQuery = new PermissionSpiceDBQuery(bulkExecutor); - this.fgaQuery = new FgaSpiceDBQuery(checkExecutor); - this.routeQuery = new RouteSpiceDBQuery(bulkExecutor); - this.lookupQuery = new LookupSpiceDBQuery(lookupResourcesExec, lookupSubjectsExec); + Supplier consistency = ConsistencyFactory.supplierFor(config.getConsistencyPolicy()); + + this.featureQuery = new FeatureSpiceDBQuery(bulkExecutor, consistency); + this.permissionQuery = new PermissionSpiceDBQuery(bulkExecutor, consistency); + this.fgaQuery = new FgaSpiceDBQuery(checkExecutor, consistency); + this.routeQuery = new RouteSpiceDBQuery(bulkExecutor, consistency); + this.lookupQuery = new LookupSpiceDBQuery(lookupResourcesExec, lookupSubjectsExec, consistency); } /** @@ -90,7 +94,8 @@ class SpiceDBQueryClient { this.routeQuery = routeQuery; this.lookupQuery = new LookupSpiceDBQuery( req -> { throw new UnsupportedOperationException("lookup not wired in test constructor"); }, - req -> { throw new UnsupportedOperationException("lookup not wired in test constructor"); }); + req -> { throw new UnsupportedOperationException("lookup not wired in test constructor"); }, + ConsistencyFactory.supplierFor(com.frontegg.sdk.entitlements.config.ConsistencyPolicy.MINIMIZE_LATENCY)); } /** diff --git a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java index 5aca849..8084420 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java @@ -3,6 +3,7 @@ import com.frontegg.sdk.entitlements.EntitlementsClient; import com.frontegg.sdk.entitlements.EntitlementsClientFactory; import com.frontegg.sdk.entitlements.config.ClientConfiguration; +import com.frontegg.sdk.entitlements.config.ConsistencyPolicy; import com.frontegg.sdk.entitlements.integration.SpiceDBSchemaWriter; import com.frontegg.sdk.entitlements.model.EntitlementsResult; import com.frontegg.sdk.entitlements.model.EntityRequestContext; @@ -53,6 +54,7 @@ static void setup() { .engineEndpoint(endpoint) .engineToken(token) .useTls(false) + .consistencyPolicy(ConsistencyPolicy.FULLY_CONSISTENT) .build(); client = EntitlementsClientFactory.create(config); diff --git a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBIntegrationTest.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBIntegrationTest.java index 92167e1..a4a246d 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBIntegrationTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBIntegrationTest.java @@ -3,6 +3,7 @@ import com.frontegg.sdk.entitlements.EntitlementsClient; import com.frontegg.sdk.entitlements.EntitlementsClientFactory; import com.frontegg.sdk.entitlements.config.ClientConfiguration; +import com.frontegg.sdk.entitlements.config.ConsistencyPolicy; import com.frontegg.sdk.entitlements.model.EntitlementsResult; import com.frontegg.sdk.entitlements.model.EntityRequestContext; import com.frontegg.sdk.entitlements.model.EntitySubjectContext; @@ -73,6 +74,7 @@ static void setup() { .engineEndpoint(spicedb.getGrpcEndpoint()) .engineToken(spicedb.getPresharedKey()) .useTls(false) + .consistencyPolicy(ConsistencyPolicy.FULLY_CONSISTENT) .build(); client = EntitlementsClientFactory.create(config); @@ -563,6 +565,7 @@ void clientClose_doesNotThrow() { .engineEndpoint(spicedb.getGrpcEndpoint()) .engineToken(spicedb.getPresharedKey()) .useTls(false) + .consistencyPolicy(ConsistencyPolicy.FULLY_CONSISTENT) .build(); EntitlementsClient ephemeral = EntitlementsClientFactory.create(config); diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java index c8796d4..95b9fb5 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java @@ -33,6 +33,9 @@ */ class FeatureSpiceDBQueryTest { + private static final java.util.function.Supplier TEST_CONSISTENCY = + () -> com.authzed.api.v1.Consistency.newBuilder().setMinimizeLatency(true).build(); + // ------------------------------------------------------------------------- // Permissionship outcome mapping // ------------------------------------------------------------------------- @@ -65,7 +68,7 @@ void query_tenantEntitledUserDenied_returnsAllowed() { } @Test - void query_userConditionalPermission_returnsAllowed() { + void query_userConditionalPermission_returnsDenied() { FeatureSpiceDBQuery query = queryWith( permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION), permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); @@ -74,11 +77,11 @@ void query_userConditionalPermission_returnsAllowed() { new UserSubjectContext("user-1", "tenant-1"), new FeatureRequestContext("feature-key")); - assertTrue(result.result(), "user conditional permission → result must be true"); + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); } @Test - void query_tenantConditionalPermission_returnsAllowed() { + void query_tenantConditionalPermission_returnsDenied() { FeatureSpiceDBQuery query = queryWith( permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION)); @@ -87,7 +90,7 @@ void query_tenantConditionalPermission_returnsAllowed() { new UserSubjectContext("user-1", "tenant-1"), new FeatureRequestContext("feature-key")); - assertTrue(result.result(), "tenant conditional permission → result must be true"); + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); } @Test @@ -114,7 +117,7 @@ void query_requestConstruction_correctSubjectTypesObjectTypesAndRelation() { FeatureSpiceDBQuery query = new FeatureSpiceDBQuery(req -> { captured.set(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-abc", "tenant-xyz"), @@ -150,7 +153,7 @@ void query_withAttributes_caveatContextAttached() { FeatureSpiceDBQuery query = new FeatureSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1", @@ -174,7 +177,7 @@ void query_withoutAttributes_caveatContextNotAttached() { FeatureSpiceDBQuery query = new FeatureSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1", Map.of()), @@ -199,7 +202,7 @@ void query_withAt_caveatContextContainsAtField() { FeatureSpiceDBQuery query = new FeatureSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1"), @@ -225,7 +228,7 @@ void query_withAtAndAttributes_caveatContextContainsBoth() { FeatureSpiceDBQuery query = new FeatureSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1", Map.of("plan", "enterprise")), @@ -248,7 +251,7 @@ void query_withNullAt_caveatContextNotAttachedWhenNoAttributes() { FeatureSpiceDBQuery query = new FeatureSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); // Explicitly pass null at — same as convenience constructor query.query( @@ -272,7 +275,7 @@ void query_withNullAt_caveatContextNotAttachedWhenNoAttributes() { */ private static FeatureSpiceDBQuery queryWith(CheckBulkPermissionsResponseItem... items) { CheckBulkPermissionsResponse response = responseWith(items); - return new FeatureSpiceDBQuery(req -> response); + return new FeatureSpiceDBQuery(req -> response, TEST_CONSISTENCY); } private static CheckBulkPermissionsResponse responseWith( diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java index 1aad4df..5db0883 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQueryTest.java @@ -26,6 +26,9 @@ */ class FgaSpiceDBQueryTest { + private static final java.util.function.Supplier TEST_CONSISTENCY = + () -> com.authzed.api.v1.Consistency.newBuilder().setMinimizeLatency(true).build(); + // ------------------------------------------------------------------------- // Permissionship outcome mapping // ------------------------------------------------------------------------- @@ -57,7 +60,7 @@ void query_permissionDenied_returnsDenied() { } @Test - void query_conditionalPermission_returnsAllowed() { + void query_conditionalPermission_returnsDenied() { FgaSpiceDBQuery query = queryWith( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION); @@ -65,7 +68,7 @@ void query_conditionalPermission_returnsAllowed() { new EntitySubjectContext("service_account", "svc-deployer-01"), new EntityRequestContext("document", "doc-789", "viewer")); - assertTrue(result.result(), "PERMISSIONSHIP_CONDITIONAL_PERMISSION → result must be true"); + assertFalse(result.result(), "PERMISSIONSHIP_CONDITIONAL_PERMISSION → result must be false (fail-closed)"); assertFalse(result.monitoring(), "monitoring must be false for normal check"); } @@ -93,7 +96,7 @@ void query_requestConstruction_correctSubjectTypeAndId() { captured.set(req); return permissionResponse( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION); - }); + }, TEST_CONSISTENCY); query.query( new EntitySubjectContext("service_account", "svc-deployer-01"), @@ -118,7 +121,7 @@ void query_requestConstruction_correctResourceTypeAndId() { captured.set(req); return permissionResponse( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION); - }); + }, TEST_CONSISTENCY); query.query( new EntitySubjectContext("service_account", "svc-deployer-01"), @@ -143,7 +146,7 @@ void query_requestConstruction_correctRelation() { captured.set(req); return permissionResponse( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION); - }); + }, TEST_CONSISTENCY); query.query( new EntitySubjectContext("service_account", "svc-deployer-01"), @@ -168,7 +171,7 @@ void query_base64Encoding_subjectIdIsUrlSafeNoPadding() { captured.set(req); return permissionResponse( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION); - }); + }, TEST_CONSISTENCY); // Use a value that would produce padding characters in standard Base64 query.query( @@ -201,7 +204,7 @@ void query_base64Encoding_unicodeEntityId_encodedCorrectly() { captured.set(req); return permissionResponse( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION); - }); + }, TEST_CONSISTENCY); query.query( new EntitySubjectContext("user", "user@example.com"), @@ -230,7 +233,7 @@ void query_withAtTimestamp_requestIncludesCaveatContext() { captured.set(req); return permissionResponse( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION); - }); + }, TEST_CONSISTENCY); java.time.Instant at = java.time.Instant.parse("2026-02-01T00:00:00Z"); query.query( @@ -253,7 +256,7 @@ void query_withNullAt_requestHasNoCaveatContext() { captured.set(req); return permissionResponse( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION); - }); + }, TEST_CONSISTENCY); query.query( new EntitySubjectContext("user", "Alice"), @@ -273,7 +276,7 @@ void query_withAtTimestamp_caveatContextUsesIso8601Format() { captured.set(req); return permissionResponse( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION); - }); + }, TEST_CONSISTENCY); java.time.Instant at = java.time.Instant.parse("2026-03-15T14:30:00Z"); query.query( @@ -298,7 +301,7 @@ void query_withAtTimestamp_caveatContextUsesIso8601Format() { private static FgaSpiceDBQuery queryWith( CheckPermissionResponse.Permissionship permissionship) { CheckPermissionResponse response = permissionResponse(permissionship); - return new FgaSpiceDBQuery(req -> response); + return new FgaSpiceDBQuery(req -> response, TEST_CONSISTENCY); } private static CheckPermissionResponse permissionResponse( diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQueryTest.java index 7a69b4a..dfc76be 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/LookupSpiceDBQueryTest.java @@ -31,6 +31,9 @@ */ class LookupSpiceDBQueryTest { + private static final java.util.function.Supplier TEST_CONSISTENCY = + () -> com.authzed.api.v1.Consistency.newBuilder().setMinimizeLatency(true).build(); + // ------------------------------------------------------------------------- // lookupResources — result decoding // ------------------------------------------------------------------------- @@ -44,7 +47,7 @@ void lookupResources_multipleResults_returnsDecodedResourceIds() { req -> iteratorOf( resourcesResponse(base64(rawId1)), resourcesResponse(base64(rawId2))), - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); LookupResult result = query.lookupResources( new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( @@ -60,7 +63,7 @@ void lookupResources_multipleResults_returnsDecodedResourceIds() { void lookupResources_emptyResult_returnsEmptyList() { LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> Collections.emptyIterator(), - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); LookupResult result = query.lookupResources( new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( @@ -76,7 +79,7 @@ void lookupResources_singleResult_returnsDecodedId() { LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> iteratorOf(resourcesResponse(base64(rawId))), - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); LookupResult result = query.lookupResources( new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( @@ -98,7 +101,7 @@ void lookupResources_requestConstruction_subjectTypeAndIdAreEncoded() { captured.set(req); return Collections.emptyIterator(); }, - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); query.lookupResources( new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( @@ -130,7 +133,7 @@ void lookupResources_subjectIdBase64_isUrlSafeNoPadding() { captured.set(req); return Collections.emptyIterator(); }, - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); // Use an ID that would produce standard Base64 padding query.lookupResources( @@ -155,7 +158,7 @@ void lookupSubjects_multipleResults_returnsDecodedSubjectIds() { req -> Collections.emptyIterator(), req -> iteratorOf( subjectsResponse(base64(rawId1)), - subjectsResponse(base64(rawId2)))); + subjectsResponse(base64(rawId2))), TEST_CONSISTENCY); LookupResult result = query.lookupSubjects( new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( @@ -171,7 +174,7 @@ void lookupSubjects_multipleResults_returnsDecodedSubjectIds() { void lookupSubjects_emptyResult_returnsEmptyList() { LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> Collections.emptyIterator(), - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); LookupResult result = query.lookupSubjects( new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( @@ -187,7 +190,7 @@ void lookupSubjects_singleResult_returnsDecodedId() { LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> Collections.emptyIterator(), - req -> iteratorOf(subjectsResponse(base64(rawId)))); + req -> iteratorOf(subjectsResponse(base64(rawId))), TEST_CONSISTENCY); LookupResult result = query.lookupSubjects( new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( @@ -209,7 +212,7 @@ void lookupSubjects_requestConstruction_resourceTypeAndIdAreEncoded() { req -> { captured.set(req); return Collections.emptyIterator(); - }); + }, TEST_CONSISTENCY); query.lookupSubjects( new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( @@ -245,7 +248,7 @@ void lookupResources_withAtTimestamp_requestIncludesCaveatContext() { captured.set(req); return Collections.emptyIterator(); }, - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); java.time.Instant at = java.time.Instant.parse("2026-03-01T00:00:00Z"); query.lookupResources( @@ -270,7 +273,7 @@ void lookupResources_withNullAt_requestHasNoCaveatContext() { captured.set(req); return Collections.emptyIterator(); }, - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); query.lookupResources( new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( @@ -295,7 +298,7 @@ void lookupSubjects_withAtTimestamp_requestIncludesCaveatContext() { req -> { captured.set(req); return Collections.emptyIterator(); - }); + }, TEST_CONSISTENCY); java.time.Instant at = java.time.Instant.parse("2026-02-01T00:00:00Z"); query.lookupSubjects( @@ -320,7 +323,7 @@ void lookupSubjects_withNullAt_requestHasNoCaveatContext() { req -> { captured.set(req); return Collections.emptyIterator(); - }); + }, TEST_CONSISTENCY); query.lookupSubjects( new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( @@ -342,7 +345,7 @@ void lookupResources_base64Roundtrip_unicodeId_encodedAndDecodedCorrectly() { LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> iteratorOf(resourcesResponse(base64(rawId))), - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); LookupResult result = query.lookupResources( new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( @@ -358,7 +361,7 @@ void lookupSubjects_base64Roundtrip_specialCharsId_encodedAndDecodedCorrectly() LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> Collections.emptyIterator(), - req -> iteratorOf(subjectsResponse(base64(rawId)))); + req -> iteratorOf(subjectsResponse(base64(rawId))), TEST_CONSISTENCY); LookupResult result = query.lookupSubjects( new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( @@ -379,7 +382,7 @@ void lookupResources_executorThrows_propagatesException() { }; LookupSpiceDBQuery query = new LookupSpiceDBQuery( failingExecutor, - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); assertThrows(StatusRuntimeException.class, () -> query.lookupResources( new com.frontegg.sdk.entitlements.model.LookupResourcesRequest( @@ -393,7 +396,7 @@ void lookupSubjects_executorThrows_propagatesException() { }; LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> Collections.emptyIterator(), - failingExecutor); + failingExecutor, TEST_CONSISTENCY); assertThrows(StatusRuntimeException.class, () -> query.lookupSubjects( new com.frontegg.sdk.entitlements.model.LookupSubjectsRequest( @@ -408,7 +411,7 @@ void lookupSubjects_executorThrows_propagatesException() { void lookupResources_nullRequest_throwsNullPointerException() { LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> Collections.emptyIterator(), - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); assertThrows(NullPointerException.class, () -> query.lookupResources(null)); } @@ -417,7 +420,7 @@ void lookupResources_nullRequest_throwsNullPointerException() { void lookupSubjects_nullRequest_throwsNullPointerException() { LookupSpiceDBQuery query = new LookupSpiceDBQuery( req -> Collections.emptyIterator(), - req -> Collections.emptyIterator()); + req -> Collections.emptyIterator(), TEST_CONSISTENCY); assertThrows(NullPointerException.class, () -> query.lookupSubjects(null)); } diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQueryTest.java index 5e0e63e..e722766 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/PermissionSpiceDBQueryTest.java @@ -6,6 +6,7 @@ import com.authzed.api.v1.CheckBulkPermissionsResponse; import com.authzed.api.v1.CheckBulkPermissionsResponseItem; import com.authzed.api.v1.CheckPermissionResponse; +import com.authzed.api.v1.Consistency; import com.authzed.api.v1.ObjectReference; import com.authzed.api.v1.SubjectReference; import com.frontegg.sdk.entitlements.model.EntitlementsResult; @@ -19,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -35,6 +37,9 @@ */ class PermissionSpiceDBQueryTest { + private static final Supplier TEST_CONSISTENCY = + () -> Consistency.newBuilder().setMinimizeLatency(true).build(); + // ------------------------------------------------------------------------- // Single permission — permissionship outcome mapping // ------------------------------------------------------------------------- @@ -43,7 +48,7 @@ class PermissionSpiceDBQueryTest { void query_singlePermission_userEntitled_returnsAllowed() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION, - CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), TEST_CONSISTENCY); EntitlementsResult result = query.query( new UserSubjectContext("user-1", "tenant-1"), @@ -57,7 +62,7 @@ void query_singlePermission_userEntitled_returnsAllowed() { void query_singlePermission_tenantEntitledUserDenied_returnsAllowed() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION, - CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION)); + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION), TEST_CONSISTENCY); EntitlementsResult result = query.query( new UserSubjectContext("user-1", "tenant-1"), @@ -67,36 +72,36 @@ void query_singlePermission_tenantEntitledUserDenied_returnsAllowed() { } @Test - void query_singlePermission_userConditionalPermission_returnsAllowed() { + void query_singlePermission_userConditionalPermission_returnsDenied() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION, - CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), TEST_CONSISTENCY); EntitlementsResult result = query.query( new UserSubjectContext("user-1", "tenant-1"), new PermissionRequestContext("reports:read")); - assertTrue(result.result(), "user conditional permission → result must be true"); + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); } @Test - void query_singlePermission_tenantConditionalPermission_returnsAllowed() { + void query_singlePermission_tenantConditionalPermission_returnsDenied() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION, - CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION)); + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION), TEST_CONSISTENCY); EntitlementsResult result = query.query( new UserSubjectContext("user-1", "tenant-1"), new PermissionRequestContext("reports:read")); - assertTrue(result.result(), "tenant conditional permission → result must be true"); + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); } @Test void query_singlePermission_bothDenied_returnsDenied() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION, - CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), TEST_CONSISTENCY); EntitlementsResult result = query.query( new UserSubjectContext("user-1", "tenant-1"), @@ -114,7 +119,7 @@ void query_multiplePermissions_allEntitled_returnsAllowed() { // Both permissions: user is entitled (tenant denied) PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION, - CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), TEST_CONSISTENCY); EntitlementsResult result = query.query( new UserSubjectContext("user-1", "tenant-1"), @@ -140,7 +145,7 @@ void query_multiplePermissions_oneKeyDenied_returnsDenied() { responseBuilder.addPairs(pairWithRequest(items.get(i), p)); } return responseBuilder.build(); - }); + }, TEST_CONSISTENCY); EntitlementsResult result = denyFirstQuery.query( new UserSubjectContext("user-1", "tenant-1"), @@ -160,7 +165,7 @@ void query_twoPermissionKeys_requestHasFourItems() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> { captured.set(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-abc", "tenant-xyz"), @@ -179,7 +184,7 @@ void query_requestConstruction_correctResourceTypeAndRelation() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> { captured.set(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-abc", "tenant-xyz"), @@ -216,7 +221,7 @@ void query_withAttributes_caveatContextAttached() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1", @@ -240,7 +245,7 @@ void query_withoutAttributes_caveatContextNotAttached() { PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1", Map.of()), diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQueryTest.java index 2a663a4..8d598c6 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQueryTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/RouteSpiceDBQueryTest.java @@ -38,6 +38,9 @@ */ class RouteSpiceDBQueryTest { + private static final java.util.function.Supplier TEST_CONSISTENCY = + () -> com.authzed.api.v1.Consistency.newBuilder().setMinimizeLatency(true).build(); + // ------------------------------------------------------------------------- // Permissionship outcome mapping // ------------------------------------------------------------------------- @@ -70,7 +73,7 @@ void query_tenantEntitledUserDenied_returnsAllowed() { } @Test - void query_userConditionalPermission_returnsAllowed() { + void query_userConditionalPermission_returnsDenied() { RouteSpiceDBQuery query = queryWith( permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION), permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION)); @@ -79,11 +82,11 @@ void query_userConditionalPermission_returnsAllowed() { new UserSubjectContext("user-1", "tenant-1"), new RouteRequestContext("GET", "/api/v1/reports")); - assertTrue(result.result(), "user conditional permission → result must be true"); + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); } @Test - void query_tenantConditionalPermission_returnsAllowed() { + void query_tenantConditionalPermission_returnsDenied() { RouteSpiceDBQuery query = queryWith( permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), permissionship(CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION)); @@ -92,7 +95,7 @@ void query_tenantConditionalPermission_returnsAllowed() { new UserSubjectContext("user-1", "tenant-1"), new RouteRequestContext("POST", "/api/v1/data")); - assertTrue(result.result(), "tenant conditional permission → result must be true"); + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); } @Test @@ -119,7 +122,7 @@ void query_routeKeyFormat_isMethodColonPath() { RouteSpiceDBQuery query = new RouteSpiceDBQuery(req -> { captured.set(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-abc", "tenant-xyz"), @@ -150,7 +153,7 @@ void query_requestConstruction_correctSubjectTypesObjectTypesAndRelation() { RouteSpiceDBQuery query = new RouteSpiceDBQuery(req -> { captured.set(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-abc", "tenant-xyz"), @@ -189,7 +192,7 @@ void query_errorInPair_throwsEntitlementsQueryException() { .build()) .build(); - RouteSpiceDBQuery query = new RouteSpiceDBQuery(req -> errorResponse); + RouteSpiceDBQuery query = new RouteSpiceDBQuery(req -> errorResponse, TEST_CONSISTENCY); assertThrows(EntitlementsQueryException.class, () -> query.query( @@ -209,7 +212,7 @@ void query_withAttributes_caveatContextAttached() { RouteSpiceDBQuery query = new RouteSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1", @@ -233,7 +236,7 @@ void query_withoutAttributes_caveatContextNotAttached() { RouteSpiceDBQuery query = new RouteSpiceDBQuery(req -> { requests.add(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1", Map.of()), @@ -265,7 +268,7 @@ void query_httpMethodCase_alwaysUppercased(String input, String expected) { RouteSpiceDBQuery query = new RouteSpiceDBQuery(req -> { captured.set(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1"), @@ -302,7 +305,7 @@ void query_pathWithSpecialCharacters_encodedCorrectly(String path) { RouteSpiceDBQuery query = new RouteSpiceDBQuery(req -> { captured.set(req); return emptyResponse(); - }); + }, TEST_CONSISTENCY); query.query( new UserSubjectContext("user-1", "tenant-1"), @@ -334,7 +337,7 @@ void query_pathWithSpecialCharacters_encodedCorrectly(String path) { */ private static RouteSpiceDBQuery queryWith(CheckBulkPermissionsResponseItem... items) { CheckBulkPermissionsResponse response = responseWith(items); - return new RouteSpiceDBQuery(req -> response); + return new RouteSpiceDBQuery(req -> response, TEST_CONSISTENCY); } private static CheckBulkPermissionsResponse responseWith( diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/SpiceDBEntitlementsClientTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/SpiceDBEntitlementsClientTest.java index 42c570e..420a604 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/SpiceDBEntitlementsClientTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/SpiceDBEntitlementsClientTest.java @@ -124,13 +124,15 @@ private static class ControlledQueryClient extends SpiceDBQueryClient { /** Creates a query client that returns the given result on execute(). */ ControlledQueryClient(EntitlementsResult result) { - super(/* featureQuery */ null, /* permissionQuery */ null); + super(/* featureQuery */ null, /* permissionQuery */ null, + /* fgaQuery */ null, /* routeQuery */ null); this.resultToReturn = result; } /** Creates a query client that throws the given exception on execute(). */ ControlledQueryClient(StatusRuntimeException error) { - super(/* featureQuery */ null, /* permissionQuery */ null); + super(/* featureQuery */ null, /* permissionQuery */ null, + /* fgaQuery */ null, /* routeQuery */ null); this.errorToThrow = error; } @@ -158,7 +160,8 @@ private static class FailThenSucceedQueryClient extends SpiceDBQueryClient { FailThenSucceedQueryClient(int failTimes, StatusRuntimeException error, EntitlementsResult successResult) { - super(/* featureQuery */ null, /* permissionQuery */ null); + super(/* featureQuery */ null, /* permissionQuery */ null, + /* fgaQuery */ null, /* routeQuery */ null); this.failTimes = failTimes; this.errorToThrow = error; this.successResult = successResult; diff --git a/src/test/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClientTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClientTest.java index d69fe8f..dafdb18 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClientTest.java +++ b/src/test/java/com/frontegg/sdk/entitlements/internal/SpiceDBQueryClientTest.java @@ -40,15 +40,18 @@ class SpiceDBQueryClientTest { @BeforeEach void setUp() { + java.util.function.Supplier consistency = + () -> com.authzed.api.v1.Consistency.newBuilder().setMinimizeLatency(true).build(); + FeatureSpiceDBQuery capturedFeatureQuery = new FeatureSpiceDBQuery(req -> { featureQueryInvoked.set(true); return CheckBulkPermissionsResponse.newBuilder().build(); - }); + }, consistency); PermissionSpiceDBQuery capturedPermissionQuery = new PermissionSpiceDBQuery(req -> { permissionQueryInvoked.set(true); return CheckBulkPermissionsResponse.newBuilder().build(); - }); + }, consistency); FgaSpiceDBQuery capturedFgaQuery = new FgaSpiceDBQuery(req -> { fgaQueryInvoked.set(true); @@ -56,12 +59,12 @@ void setUp() { .setPermissionship( CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION) .build(); - }); + }, consistency); RouteSpiceDBQuery capturedRouteQuery = new RouteSpiceDBQuery(req -> { routeQueryInvoked.set(true); return CheckBulkPermissionsResponse.newBuilder().build(); - }); + }, consistency); queryClient = new SpiceDBQueryClient(capturedFeatureQuery, capturedPermissionQuery, capturedFgaQuery, capturedRouteQuery); From e2d855bdb7e0e508d5cf8a9482f95792ced4c494 Mon Sep 17 00:00:00 2001 From: Raz Shlomo Date: Sun, 15 Mar 2026 18:55:22 +0200 Subject: [PATCH 28/28] fix(e2e): explicitly set null caveat params to fix CONDITIONAL_PERMISSION SpiceDB returns CONDITIONAL_PERMISSION when caveat parameters are missing from the context. The activeUntil param was omitted when null, preventing SpiceDB from evaluating the `activeUntil == null` check in the caveat expression. Now explicitly sets null values so the caveat fully resolves. --- .../sdk/entitlements/integration/SpiceDBSchemaWriter.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java index e19dd27..d9ec19a 100644 --- a/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java +++ b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java @@ -209,10 +209,16 @@ private static RelationshipUpdate buildCaveatUpdate(String resourceType, String if (activeFrom != null) { contextBuilder.putFields("activeFrom", Value.newBuilder().setStringValue(activeFrom).build()); + } else { + contextBuilder.putFields("activeFrom", + Value.newBuilder().setNullValueValue(0).build()); } if (activeUntil != null) { contextBuilder.putFields("activeUntil", Value.newBuilder().setStringValue(activeUntil).build()); + } else { + contextBuilder.putFields("activeUntil", + Value.newBuilder().setNullValueValue(0).build()); } return RelationshipUpdate.newBuilder()