diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 17f09e6..ab61554 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: @@ -22,6 +22,42 @@ 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@v6 + - 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 + - name: Wait for SpiceDB readiness + run: | + 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 60 ]; then + echo "SpiceDB did not become ready" + docker compose -f e2e/docker-compose.yml logs spicedb + 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/.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: 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/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 0000000..0a15cfe --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,19 @@ +services: + spicedb: + image: authzed/spicedb:v1.42.1 + ports: + - '50051:50051' + command: + [ + 'serve', + '--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: 5s + timeout: 3s + retries: 5 + start_period: 5s \ No newline at end of file diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh new file mode 100755 index 0000000..b552a35 --- /dev/null +++ b/e2e/run-e2e.sh @@ -0,0 +1,46 @@ +#!/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 + +echo "Waiting for SpiceDB to be ready..." +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 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 +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/entitlements-client-spring-boot-starter/pom.xml b/entitlements-client-spring-boot-starter/pom.xml index 62b4cbe..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 @@ -81,7 +81,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 + 3.15.0 ${java.version} ${java.version} @@ -124,7 +124,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.2.7 + 3.2.8 sign-artifacts 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 88af3a3..7a4a32b 100644 --- a/pom.xml +++ b/pom.xml @@ -48,15 +48,15 @@ 1.5.4 1.78.0 4.33.5 - 2.0.16 + 2.0.17 5.10.3 - 5.14.2 + 5.22.0 3.13.0 - 3.2.5 - 3.3.1 - 3.7.0 + 3.5.5 + 3.4.0 + 3.12.0 3.2.7 0.10.0 @@ -113,7 +113,7 @@ com.google.api.grpc grpc-google-common-protos - 2.37.1 + 2.66.0 @@ -127,7 +127,7 @@ com.github.ben-manes.caffeine caffeine - 3.1.8 + 3.2.3 true @@ -194,6 +194,7 @@ false **/integration/** + **/e2e/** @@ -277,7 +278,7 @@ org.apache.maven.plugins maven-failsafe-plugin - 3.2.5 + ${maven-surefire-plugin.version} **/integration/**Test.java @@ -295,5 +296,34 @@ + + e2e + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-surefire-plugin.version} + + + **/e2e/**Test.java + + + ${spicedb.endpoint} + ${spicedb.token} + + + + + + integration-test + verify + + + + + + + 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 bdfb0be..cf7bff6 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; @@ -15,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. * @@ -42,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; } /** @@ -76,6 +81,7 @@ EntitlementsResult query(UserSubjectContext userCtx, FeatureRequestContext featu TYPE_TENANT, b64TenantId, featureResource, caveatContext); CheckBulkPermissionsRequest request = CheckBulkPermissionsRequest.newBuilder() + .setConsistency(consistencySupplier.get()) .addItems(userItem) .addItems(tenantItem) .build(); @@ -89,6 +95,17 @@ 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()) 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..bee997a 100644 --- a/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java +++ b/src/main/java/com/frontegg/sdk/entitlements/internal/FgaSpiceDBQuery.java @@ -2,14 +2,18 @@ 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; 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; +import java.util.function.Supplier; + /** * Package-private query strategy that handles fine-grained authorization (FGA) checks * for {@link EntityRequestContext} requests. @@ -33,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; } /** @@ -67,14 +73,32 @@ 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() + .setConsistency(consistencySupplier.get()) .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); + 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; 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..28ee297 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; @@ -7,12 +8,14 @@ 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; 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 @@ -32,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; } /** @@ -52,8 +58,11 @@ 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() + .setConsistency(consistencySupplier.get()) .setSubject(SubjectReference.newBuilder() .setObject(ObjectReference.newBuilder() .setObjectType(request.subjectType()) @@ -61,8 +70,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 +106,23 @@ 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() + .setConsistency(consistencySupplier.get()) .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..11d66fa 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; @@ -16,6 +17,7 @@ import org.slf4j.LoggerFactory; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -49,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; } /** @@ -74,7 +78,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(consistencySupplier.get()); for (String permissionKey : permissionCtx.permissionKeys()) { String b64PermissionKey = Base64Utils.encode(permissionKey); @@ -97,6 +102,17 @@ 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) 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..82b2314 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; @@ -15,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. * @@ -45,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; } /** @@ -81,6 +86,7 @@ EntitlementsResult query(UserSubjectContext userCtx, RouteRequestContext routeCt TYPE_TENANT, b64TenantId, routeResource, caveatContext); CheckBulkPermissionsRequest request = CheckBulkPermissionsRequest.newBuilder() + .setConsistency(consistencySupplier.get()) .addItems(userItem) .addItems(tenantItem) .build(); @@ -95,6 +101,17 @@ 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()) 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/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..8084420 --- /dev/null +++ b/src/test/java/com/frontegg/sdk/entitlements/e2e/SpiceDBE2ETest.java @@ -0,0 +1,214 @@ +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.config.ConsistencyPolicy; +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) + .consistencyPolicy(ConsistencyPolicy.FULLY_CONSISTENT) + .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..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; @@ -13,6 +14,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,11 +68,13 @@ static void setup() { spicedb.getGrpcEndpoint(), spicedb.getPresharedKey()); schemaWriter.writeSchema(); schemaWriter.writeRelationships(); + schemaWriter.writeCaveatRelationships(); ClientConfiguration config = ClientConfiguration.builder() .engineEndpoint(spicedb.getGrpcEndpoint()) .engineToken(spicedb.getPresharedKey()) .useTls(false) + .consistencyPolicy(ConsistencyPolicy.FULLY_CONSISTENT) .build(); client = EntitlementsClientFactory.create(config); @@ -367,6 +372,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 // ------------------------------------------------------------------------- @@ -379,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/integration/SpiceDBSchemaWriter.java b/src/test/java/com/frontegg/sdk/entitlements/integration/SpiceDBSchemaWriter.java index f0618e7..d9ec19a 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,49 @@ 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()); + } 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() + .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/FeatureSpiceDBQueryTest.java b/src/test/java/com/frontegg/sdk/entitlements/internal/FeatureSpiceDBQueryTest.java index 24134ca..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 // ------------------------------------------------------------------------- @@ -64,6 +67,32 @@ void query_tenantEntitledUserDenied_returnsAllowed() { assertTrue(result.result(), "tenant entitled → result must be true even if user denied"); } + @Test + void query_userConditionalPermission_returnsDenied() { + 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")); + + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); + } + + @Test + void query_tenantConditionalPermission_returnsDenied() { + 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")); + + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); + } + @Test void query_bothDenied_returnsDenied() { FeatureSpiceDBQuery query = queryWith( @@ -88,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"), @@ -124,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", @@ -148,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()), @@ -173,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"), @@ -199,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")), @@ -222,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( @@ -246,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 a46ad14..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 // ------------------------------------------------------------------------- @@ -56,6 +59,19 @@ void query_permissionDenied_returnsDenied() { assertFalse(result.monitoring(), "monitoring must be false for normal check"); } + @Test + void query_conditionalPermission_returnsDenied() { + 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")); + + assertFalse(result.result(), "PERMISSIONSHIP_CONDITIONAL_PERMISSION → result must be false (fail-closed)"); + assertFalse(result.monitoring(), "monitoring must be false for normal check"); + } + @Test void query_unspecifiedPermissionship_returnsDenied() { FgaSpiceDBQuery query = queryWith( @@ -80,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"), @@ -105,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"), @@ -130,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"), @@ -155,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( @@ -188,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"), @@ -205,6 +221,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); + }, TEST_CONSISTENCY); + + 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); + }, TEST_CONSISTENCY); + + 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); + }, TEST_CONSISTENCY); + + 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 // ------------------------------------------------------------------------- @@ -216,7 +301,7 @@ void query_base64Encoding_unicodeEntityId_encodedCorrectly() { 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 ad8961e..dfc76be 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; @@ -30,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 // ------------------------------------------------------------------------- @@ -43,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( @@ -59,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( @@ -75,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( @@ -97,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( @@ -129,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( @@ -154,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( @@ -170,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( @@ -186,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( @@ -208,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( @@ -231,6 +235,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(), TEST_CONSISTENCY); + + 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(), TEST_CONSISTENCY); + + 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(); + }, TEST_CONSISTENCY); + + 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(); + }, TEST_CONSISTENCY); + + 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 // ------------------------------------------------------------------------- @@ -241,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( @@ -257,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( @@ -278,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( @@ -292,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( @@ -307,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)); } @@ -316,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 08006d0..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"), @@ -66,11 +71,37 @@ void query_singlePermission_tenantEntitledUserDenied_returnsAllowed() { assertTrue(result.result(), "tenant entitled → result must be true even if user denied"); } + @Test + void query_singlePermission_userConditionalPermission_returnsDenied() { + PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> + responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION, + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION), TEST_CONSISTENCY); + + EntitlementsResult result = query.query( + new UserSubjectContext("user-1", "tenant-1"), + new PermissionRequestContext("reports:read")); + + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); + } + + @Test + void query_singlePermission_tenantConditionalPermission_returnsDenied() { + PermissionSpiceDBQuery query = new PermissionSpiceDBQuery(req -> + responseForRequest(req, CheckPermissionResponse.Permissionship.PERMISSIONSHIP_NO_PERMISSION, + CheckPermissionResponse.Permissionship.PERMISSIONSHIP_CONDITIONAL_PERMISSION), TEST_CONSISTENCY); + + EntitlementsResult result = query.query( + new UserSubjectContext("user-1", "tenant-1"), + new PermissionRequestContext("reports:read")); + + 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"), @@ -88,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"), @@ -114,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"), @@ -134,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"), @@ -153,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"), @@ -190,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", @@ -214,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 d78a5de..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 // ------------------------------------------------------------------------- @@ -69,6 +72,32 @@ void query_tenantEntitledUserDenied_returnsAllowed() { assertTrue(result.result(), "tenant entitled → result must be true even if user denied"); } + @Test + void query_userConditionalPermission_returnsDenied() { + 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")); + + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); + } + + @Test + void query_tenantConditionalPermission_returnsDenied() { + 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")); + + assertFalse(result.result(), "conditional permission → result must be false (fail-closed)"); + } + @Test void query_bothDenied_returnsDenied() { RouteSpiceDBQuery query = queryWith( @@ -93,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"), @@ -124,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"), @@ -163,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( @@ -183,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", @@ -207,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()), @@ -239,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"), @@ -276,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"), @@ -308,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); 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); + } } // =========================================================================