From e08082d4571820907a7d00d3452fcadc94f616e0 Mon Sep 17 00:00:00 2001 From: Gabriel Hall Date: Thu, 25 Jun 2026 10:08:56 -0700 Subject: [PATCH 1/2] feat: support contextual tuples and context in checks Add check(...) overloads that accept contextual tuples and a context object, which are evaluated as if those tuples existed in the store and used to satisfy conditions defined in the authorization model. Closes #43 --- src/main/java/dev/openfga/OpenFga.java | 68 ++++++++++++++++++ src/test/java/dev/openfga/OpenFgaTest.java | 83 ++++++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/src/main/java/dev/openfga/OpenFga.java b/src/main/java/dev/openfga/OpenFga.java index 2231c33..44bc790 100644 --- a/src/main/java/dev/openfga/OpenFga.java +++ b/src/main/java/dev/openfga/OpenFga.java @@ -2,7 +2,9 @@ import dev.openfga.sdk.api.client.OpenFgaClient; import dev.openfga.sdk.api.client.model.ClientCheckRequest; +import dev.openfga.sdk.api.client.model.ClientTupleKey; import dev.openfga.sdk.errors.FgaInvalidParameterException; +import java.util.List; import java.util.concurrent.ExecutionException; import org.springframework.security.core.context.SecurityContextHolder; @@ -69,10 +71,76 @@ public boolean check(String objectType, String objectId, String relation, String * @see FGA Check API */ public boolean check(String objectType, String objectId, String relation, String userType, String userId) { + return check(objectType, objectId, relation, userType, userId, null, null); + } + + /** + * Perform an FGA check, supplying contextual tuples and/or a context object for the evaluation. Returns {@code true} + * if the user has the specified relationship with the object, {@code false} otherwise. The user ID will be obtained + * from the authentication name in the {@link org.springframework.security.core.context.SecurityContext}.

+ * Contextual tuples are evaluated as if they existed in the store, and the context object can be used to satisfy + * conditions defined in the authorization model. + * + * @param objectType The object type of the check + * @param objectId The ID of the object to check + * @param relation The required relation between the user and the object + * @param userType The type of the user + * @param contextualTuples The contextual tuples to evaluate as part of the check, may be {@code null} + * @param context The context object used to evaluate conditions in the authorization model, may be {@code null} + * @return true if the user has the required relation to the object, false otherwise + * + * @see FGA Check API + */ + public boolean check( + String objectType, + String objectId, + String relation, + String userType, + List contextualTuples, + Object context) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new IllegalStateException( + "No user provided, and no authentication could be found in the security context"); + } + return check(objectType, objectId, relation, userType, authentication.getName(), contextualTuples, context); + } + + /** + * Perform an FGA check, supplying contextual tuples and/or a context object for the evaluation. Returns {@code true} + * if the user has the specified relationship with the object, {@code false} otherwise.

Contextual tuples are + * evaluated as if they existed in the store, and the context object can be used to satisfy conditions defined in the + * authorization model. + * + * @param objectType The object type of the check + * @param objectId The ID of the object to check + * @param relation The required relation between the user and the object + * @param userType The type of the user + * @param userId The ID of the user + * @param contextualTuples The contextual tuples to evaluate as part of the check, may be {@code null} + * @param context The context object used to evaluate conditions in the authorization model, may be {@code null} + * @return true if the user has the required relation to the object, false otherwise + * + * @see FGA Check API + */ + public boolean check( + String objectType, + String objectId, + String relation, + String userType, + String userId, + List contextualTuples, + Object context) { var body = new ClientCheckRequest() .user(String.format("%s:%s", userType, userId)) .relation(relation) ._object(String.format("%s:%s", objectType, objectId)); + if (contextualTuples != null) { + body.contextualTuples(contextualTuples); + } + if (context != null) { + body.context(context); + } try { return Boolean.TRUE.equals(fgaClient.check(body).get().getAllowed()); } catch (InterruptedException | FgaInvalidParameterException | ExecutionException cause) { diff --git a/src/test/java/dev/openfga/OpenFgaTest.java b/src/test/java/dev/openfga/OpenFgaTest.java index 9d851b1..8f87ebb 100644 --- a/src/test/java/dev/openfga/OpenFgaTest.java +++ b/src/test/java/dev/openfga/OpenFgaTest.java @@ -9,9 +9,13 @@ import dev.openfga.sdk.api.client.OpenFgaClient; import dev.openfga.sdk.api.client.model.ClientCheckRequest; import dev.openfga.sdk.api.client.model.ClientCheckResponse; +import dev.openfga.sdk.api.client.model.ClientTupleKey; import dev.openfga.sdk.errors.FgaInvalidParameterException; import java.security.Principal; +import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; @@ -32,6 +36,11 @@ class OpenFgaTest { @Mock private ClientCheckResponse mockCheckResponse; + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + @Test void fgaCheckCalled() throws Exception { @@ -94,6 +103,80 @@ void failsWhenNoUserIdSpecifiedAndNotFoundInContext() { is("No user provided, and no authentication could be found in the security context")); } + @Test + void fgaCheckCalledWithContextualTuplesAndContext() throws Exception { + + // given + OpenFga openFga = new OpenFga(mockClient, exceptionHandler); + when(mockCheckResponseFuture.get()).thenReturn(mockCheckResponse); + when(mockClient.check(any(ClientCheckRequest.class))).thenReturn(mockCheckResponseFuture); + + List contextualTuples = List.of( + new ClientTupleKey().user("user:userId").relation("member")._object("group:marketing")); + Map context = Map.of("current_time", "2023-01-01T00:10:01Z"); + + // when + openFga.check("document", "docId", "viewer", "user", "userId", contextualTuples, context); + + // then + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ClientCheckRequest.class); + + verify(mockClient, times(1)).check(any(ClientCheckRequest.class)); + verify(mockClient).check(argumentCaptor.capture()); + + ClientCheckRequest request = argumentCaptor.getValue(); + assertThat(request.getObject(), is("document:docId")); + assertThat(request.getRelation(), is("viewer")); + assertThat(request.getUser(), is("user:userId")); + assertThat(request.getContextualTuples(), is(contextualTuples)); + assertThat(request.getContext(), is(context)); + } + + @Test + void fgaCheckWithContextualTuplesUsesPrincipalNameAsUserId() throws Exception { + // given + Principal principal = () -> "userId"; + Authentication auth = new UsernamePasswordAuthenticationToken(principal, null); + SecurityContextHolder.getContext().setAuthentication(auth); + + OpenFga openFga = new OpenFga(mockClient, exceptionHandler); + when(mockCheckResponseFuture.get()).thenReturn(mockCheckResponse); + when(mockClient.check(any(ClientCheckRequest.class))).thenReturn(mockCheckResponseFuture); + + List contextualTuples = List.of( + new ClientTupleKey().user("user:userId").relation("member")._object("group:marketing")); + Map context = Map.of("current_time", "2023-01-01T00:10:01Z"); + + // when + openFga.check("document", "docId", "viewer", "user", contextualTuples, context); + + // then + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ClientCheckRequest.class); + + verify(mockClient, times(1)).check(any(ClientCheckRequest.class)); + verify(mockClient).check(argumentCaptor.capture()); + + ClientCheckRequest request = argumentCaptor.getValue(); + assertThat(request.getObject(), is("document:docId")); + assertThat(request.getRelation(), is("viewer")); + assertThat(request.getUser(), is("user:userId")); + assertThat(request.getContextualTuples(), is(contextualTuples)); + assertThat(request.getContext(), is(context)); + } + + @Test + void failsWhenNoUserIdSpecifiedForContextualCheckAndNotFoundInContext() { + // given + OpenFga openFga = new OpenFga(mockClient, exceptionHandler); + + // when/then + IllegalStateException exception = assertThrows( + IllegalStateException.class, () -> openFga.check("document", "docId", "viewer", "user", null, null)); + assertThat( + exception.getMessage(), + is("No user provided, and no authentication could be found in the security context")); + } + @Test void failsWhenCheckHasException() throws Exception { // given From eb80a818d43424d4e15f42515992983d173789b6 Mon Sep 17 00:00:00 2001 From: Gabriel Hall Date: Thu, 25 Jun 2026 10:34:33 -0700 Subject: [PATCH 2/2] test: collapse redundant check verifications --- src/test/java/dev/openfga/OpenFgaTest.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/test/java/dev/openfga/OpenFgaTest.java b/src/test/java/dev/openfga/OpenFgaTest.java index 8f87ebb..e669f7c 100644 --- a/src/test/java/dev/openfga/OpenFgaTest.java +++ b/src/test/java/dev/openfga/OpenFgaTest.java @@ -55,7 +55,6 @@ void fgaCheckCalled() throws Exception { // then ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ClientCheckRequest.class); - verify(mockClient, times(1)).check(any(ClientCheckRequest.class)); verify(mockClient).check(argumentCaptor.capture()); ClientCheckRequest request = argumentCaptor.getValue(); @@ -81,7 +80,6 @@ void usesPrincipalNameAsUserId() throws Exception { // then ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ClientCheckRequest.class); - verify(mockClient, times(1)).check(any(ClientCheckRequest.class)); verify(mockClient).check(argumentCaptor.capture()); ClientCheckRequest request = argumentCaptor.getValue(); @@ -121,7 +119,6 @@ void fgaCheckCalledWithContextualTuplesAndContext() throws Exception { // then ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ClientCheckRequest.class); - verify(mockClient, times(1)).check(any(ClientCheckRequest.class)); verify(mockClient).check(argumentCaptor.capture()); ClientCheckRequest request = argumentCaptor.getValue(); @@ -153,7 +150,6 @@ void fgaCheckWithContextualTuplesUsesPrincipalNameAsUserId() throws Exception { // then ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ClientCheckRequest.class); - verify(mockClient, times(1)).check(any(ClientCheckRequest.class)); verify(mockClient).check(argumentCaptor.capture()); ClientCheckRequest request = argumentCaptor.getValue();