This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 08ac676186c3 CAMEL-26896: camel-openfga - preserve the relationship 
condition on tuple writes and read projections (#29744)
08ac676186c3 is described below

commit 08ac676186c3bfa34e9cb7ad65cbb6e83b3617b5
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Nov 13 15:54:24 2026 +0100

    CAMEL-26896: camel-openfga - preserve the relationship condition on tuple 
writes and read projections (#29744)
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../camel/catalog/docs/openfga-component.adoc      |  15 +-
 .../src/main/docs/openfga-component.adoc           |  15 +-
 .../camel/component/openfga/OpenFgaProducer.java   |  83 +++++++-
 .../openfga/OpenFgaTupleConditionTest.java         | 213 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  11 ++
 5 files changed, 324 insertions(+), 13 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openfga-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openfga-component.adoc
index bb37a914371d..55c9c35abb76 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openfga-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openfga-component.adoc
@@ -329,6 +329,13 @@ For several tuples at once, leave 
`user`/`relation`/`object` unset and put them
 `Map`s with `user`, `relation` and `object` entries, or of `ClientTupleKey` 
objects. `deleteTuples` accepts the same
 shapes and revokes instead.
 
+A `writeTuples` body may also set a relationship condition on a tuple: a 
`ClientTupleKey` built with `.condition(..)`,
+or a `Map` whose `condition` entry is itself a map of a `name` and an optional 
`context`. The condition is written
+through as given, so a conditioned grant stays conditioned; a tuple with no 
condition is written unconditioned. Because
+a condition can ride only on the body, a tuple configured on the endpoint is 
always written without one; set the
+condition on the body when you need a conditioned tuple. A `deleteTuples` body 
may carry a condition too, but it is
+ignored, because OpenFGA deletes by identity.
+
 [IMPORTANT]
 .The endpoint wins over the body
 ====
@@ -625,8 +632,9 @@ from somewhere the caller cannot reach, which in practice 
means the endpoint.
 
 `readTuples` answers "what access exists", as opposed to `check`'s "may this 
subject do this". Here `user`, `relation`
 and `object` are a *filter* rather than a subject, and the body comes back as 
a list of maps keyed `user`, `relation`,
-`object` and `timestamp` — deliberately the keys `writeTuples` and 
`deleteTuples` accept, so a route can revoke what
-it just read without reshaping anything:
+`object` and `timestamp`, plus a `condition` map of `name` and `context` when 
the stored tuple has one — deliberately
+the keys `writeTuples` and `deleteTuples` accept, so a route can revoke what 
it just read, or re-grant it with its
+condition intact, without reshaping anything:
 
 [tabs]
 ====
@@ -683,7 +691,8 @@ silently widens when a value goes missing is the read-side 
form of failing open.
 ====
 
 `readChanges` reads the change log, which is the building block for keeping a 
cache or a projection in step with the
-graph. Each entry adds an `operation` of `WRITE` or `DELETE`.
+graph. Each entry adds an `operation` of `WRITE` or `DELETE`, and carries the 
same `condition` map as `readTuples`
+when the changed tuple has one.
 
 Both are paged. `pageSize` bounds one request, and the token the page returns 
arrives on
 `CamelOpenFgaContinuationToken`; feed it back through `continuationToken` to 
read on.
diff --git a/components/camel-openfga/src/main/docs/openfga-component.adoc 
b/components/camel-openfga/src/main/docs/openfga-component.adoc
index bb37a914371d..55c9c35abb76 100644
--- a/components/camel-openfga/src/main/docs/openfga-component.adoc
+++ b/components/camel-openfga/src/main/docs/openfga-component.adoc
@@ -329,6 +329,13 @@ For several tuples at once, leave 
`user`/`relation`/`object` unset and put them
 `Map`s with `user`, `relation` and `object` entries, or of `ClientTupleKey` 
objects. `deleteTuples` accepts the same
 shapes and revokes instead.
 
+A `writeTuples` body may also set a relationship condition on a tuple: a 
`ClientTupleKey` built with `.condition(..)`,
+or a `Map` whose `condition` entry is itself a map of a `name` and an optional 
`context`. The condition is written
+through as given, so a conditioned grant stays conditioned; a tuple with no 
condition is written unconditioned. Because
+a condition can ride only on the body, a tuple configured on the endpoint is 
always written without one; set the
+condition on the body when you need a conditioned tuple. A `deleteTuples` body 
may carry a condition too, but it is
+ignored, because OpenFGA deletes by identity.
+
 [IMPORTANT]
 .The endpoint wins over the body
 ====
@@ -625,8 +632,9 @@ from somewhere the caller cannot reach, which in practice 
means the endpoint.
 
 `readTuples` answers "what access exists", as opposed to `check`'s "may this 
subject do this". Here `user`, `relation`
 and `object` are a *filter* rather than a subject, and the body comes back as 
a list of maps keyed `user`, `relation`,
-`object` and `timestamp` — deliberately the keys `writeTuples` and 
`deleteTuples` accept, so a route can revoke what
-it just read without reshaping anything:
+`object` and `timestamp`, plus a `condition` map of `name` and `context` when 
the stored tuple has one — deliberately
+the keys `writeTuples` and `deleteTuples` accept, so a route can revoke what 
it just read, or re-grant it with its
+condition intact, without reshaping anything:
 
 [tabs]
 ====
@@ -683,7 +691,8 @@ silently widens when a value goes missing is the read-side 
form of failing open.
 ====
 
 `readChanges` reads the change log, which is the building block for keeping a 
cache or a projection in step with the
-graph. Each entry adds an `operation` of `WRITE` or `DELETE`.
+graph. Each entry adds an `operation` of `WRITE` or `DELETE`, and carries the 
same `condition` map as `readTuples`
+when the changed tuple has one.
 
 Both are paged. `pageSize` bounds one request, and the token the page returns 
arrives on
 `CamelOpenFgaContinuationToken`; feed it back through `continuationToken` to 
read on.
diff --git 
a/components/camel-openfga/src/main/java/org/apache/camel/component/openfga/OpenFgaProducer.java
 
b/components/camel-openfga/src/main/java/org/apache/camel/component/openfga/OpenFgaProducer.java
index 8588e8f74608..654ac92a6121 100644
--- 
a/components/camel-openfga/src/main/java/org/apache/camel/component/openfga/OpenFgaProducer.java
+++ 
b/components/camel-openfga/src/main/java/org/apache/camel/component/openfga/OpenFgaProducer.java
@@ -36,6 +36,7 @@ import 
dev.openfga.sdk.api.client.model.ClientReadChangesRequest;
 import dev.openfga.sdk.api.client.model.ClientReadChangesResponse;
 import dev.openfga.sdk.api.client.model.ClientReadRequest;
 import dev.openfga.sdk.api.client.model.ClientReadResponse;
+import dev.openfga.sdk.api.client.model.ClientRelationshipCondition;
 import dev.openfga.sdk.api.client.model.ClientTupleKey;
 import dev.openfga.sdk.api.client.model.ClientTupleKeyWithoutCondition;
 import dev.openfga.sdk.api.configuration.ClientBatchCheckClientOptions;
@@ -47,6 +48,7 @@ import 
dev.openfga.sdk.api.configuration.ClientReadChangesOptions;
 import dev.openfga.sdk.api.configuration.ClientReadOptions;
 import dev.openfga.sdk.api.model.ConsistencyPreference;
 import dev.openfga.sdk.api.model.FgaObject;
+import dev.openfga.sdk.api.model.RelationshipCondition;
 import dev.openfga.sdk.api.model.TupleChange;
 import dev.openfga.sdk.api.model.TupleKey;
 import dev.openfga.sdk.api.model.User;
@@ -403,6 +405,12 @@ public class OpenFgaProducer extends DefaultProducer {
                 entry.put("user", key.getUser());
                 entry.put("relation", key.getRelation());
                 entry.put("object", key.getObject());
+                // a stored tuple can carry a relationship condition; surface 
it so a route can read it back and feed
+                // it straight into a write, the same way the identity parts 
round-trip
+                RelationshipCondition condition = key.getCondition();
+                if (condition != null) {
+                    entry.put("condition", conditionAsMap(condition));
+                }
                 entry.put("timestamp", tuple.getTimestamp());
                 tuples.add(entry);
             }
@@ -446,6 +454,10 @@ public class OpenFgaProducer extends DefaultProducer {
                 entry.put("user", key.getUser());
                 entry.put("relation", key.getRelation());
                 entry.put("object", key.getObject());
+                RelationshipCondition condition = key.getCondition();
+                if (condition != null) {
+                    entry.put("condition", conditionAsMap(condition));
+                }
                 // the enum's name rather than the enum, so a route can 
compare against "WRITE" without importing
                 // an SDK type, and so the body marshals without a custom 
serializer
                 entry.put("operation", change.getOperation() != null ? 
change.getOperation().name() : null);
@@ -586,7 +598,13 @@ public class OpenFgaProducer extends DefaultProducer {
     private void writeTuples(Exchange exchange, OpenFgaAuthorizer authorizer) 
throws Exception {
         List<ClientTupleKey> tuples = new ArrayList<>();
         for (Tuple tuple : resolveTuples(exchange, authorizer)) {
-            tuples.add(new 
ClientTupleKey().user(tuple.user).relation(tuple.relation)._object(tuple.object));
+            ClientTupleKey key = new 
ClientTupleKey().user(tuple.user).relation(tuple.relation)._object(tuple.object);
+            if (tuple.condition != null) {
+                // carry the relationship condition from the input; rebuilding 
the tuple from its three identity parts
+                // alone would store an unconditioned tuple in place of the 
conditioned one the route asked for
+                key.condition(tuple.condition);
+            }
+            tuples.add(key);
         }
         // the write options carry no authorizationModelId - unlike every 
query's options - so the model this writes
         // against is the one pinned on the client, which OpenFgaClientFactory 
already set from the configuration
@@ -629,8 +647,8 @@ public class OpenFgaProducer extends DefaultProducer {
             // a part that is configured but resolved to nothing, like a 
partly configured triple, is a mistake rather
             // than an invitation to fill the rest in from the message, so 
validated() reports which part it was
             return List.of(validated(new Tuple(
-                    authorizer.rawUser(exchange), 
authorizer.rawRelation(exchange), authorizer.rawObject(exchange)),
-                    true));
+                    authorizer.rawUser(exchange), 
authorizer.rawRelation(exchange), authorizer.rawObject(exchange),
+                    null), true));
         }
 
         List<Tuple> tuples = new ArrayList<>();
@@ -654,11 +672,16 @@ public class OpenFgaProducer extends DefaultProducer {
 
     private Tuple toTuple(Object element) {
         if (element instanceof ClientTupleKeyWithoutCondition key) {
-            return validated(new Tuple(key.getUser(), key.getRelation(), 
key.getObject()), false);
+            // getCondition() lives on the ClientTupleKey subtype only, so a 
tuple that carries a relationship
+            // condition arrives here as the supertype; read the condition 
when the concrete type has one
+            ClientRelationshipCondition condition
+                    = key instanceof ClientTupleKey conditioned ? 
conditioned.getCondition() : null;
+            return validated(new Tuple(key.getUser(), key.getRelation(), 
key.getObject(), condition), false);
         }
         if (element instanceof Map<?, ?> map) {
             return validated(new Tuple(
-                    stringValue(map, "user"), stringValue(map, "relation"), 
stringValue(map, "object")), false);
+                    stringValue(map, "user"), stringValue(map, "relation"), 
stringValue(map, "object"),
+                    toCondition(map.get("condition"))), false);
         }
         throw new IllegalArgumentException(
                 "A relationship tuple must be a Map with user, relation and 
object entries, or a ClientTupleKey, but was "
@@ -700,6 +723,49 @@ public class OpenFgaProducer extends DefaultProducer {
         return value != null ? value.toString().trim() : null;
     }
 
+    /**
+     * Builds the relationship condition a write should carry from the way the 
body expressed it: an SDK
+     * {@link ClientRelationshipCondition} as is, or a {@code Map} of {@code 
name} and {@code context} - the shape
+     * {@link #readTuples} and {@link #readChanges} project a stored condition 
as, so a route can feed one straight
+     * back. A body with no condition keeps writing an unconditioned tuple.
+     */
+    private static ClientRelationshipCondition toCondition(Object raw) {
+        if (raw == null) {
+            return null;
+        }
+        if (raw instanceof ClientRelationshipCondition condition) {
+            return condition;
+        }
+        if (raw instanceof Map<?, ?> map) {
+            String name = stringValue(map, "name");
+            if (ObjectHelper.isEmpty(name)) {
+                throw new IllegalArgumentException(
+                        "A relationship tuple condition must name a condition, 
but the condition entry had no name");
+            }
+            ClientRelationshipCondition condition = new 
ClientRelationshipCondition().name(name);
+            Object context = map.get("context");
+            if (context != null) {
+                condition.context(context);
+            }
+            return condition;
+        }
+        throw new IllegalArgumentException(
+                "A relationship tuple condition must be a Map with name and 
context entries or a"
+                                           + " ClientRelationshipCondition, 
but was " + raw.getClass().getName());
+    }
+
+    /**
+     * Projects a stored condition the server returned as a {@code Map} of 
{@code name} and {@code context}, mirroring
+     * how {@code operation} is projected as a {@code String} rather than an 
SDK type, so the read body marshals without
+     * a custom serializer and a route can round-trip it back into a write.
+     */
+    private static Map<String, Object> conditionAsMap(RelationshipCondition 
condition) {
+        Map<String, Object> projected = new LinkedHashMap<>();
+        projected.put("name", condition.getName());
+        projected.put("context", condition.getContext());
+        return projected;
+    }
+
     /**
      * Reads the objects a {@code batchCheck} should cover out of the body, 
accepting a collection or a single
      * comma-separated value.
@@ -817,17 +883,20 @@ public class OpenFgaProducer extends DefaultProducer {
     }
 
     /**
-     * A relationship tuple on its way to OpenFGA, before it is turned into 
whichever SDK type the operation needs.
+     * A relationship tuple on its way to OpenFGA, before it is turned into 
whichever SDK type the operation needs. The
+     * condition is optional and only the write path carries it; a delete is 
by identity and ignores it.
      */
     private static final class Tuple {
         private final String user;
         private final String relation;
         private final String object;
+        private final ClientRelationshipCondition condition;
 
-        private Tuple(String user, String relation, String object) {
+        private Tuple(String user, String relation, String object, 
ClientRelationshipCondition condition) {
             this.user = user;
             this.relation = relation;
             this.object = object;
+            this.condition = condition;
         }
     }
 }
diff --git 
a/components/camel-openfga/src/test/java/org/apache/camel/component/openfga/OpenFgaTupleConditionTest.java
 
b/components/camel-openfga/src/test/java/org/apache/camel/component/openfga/OpenFgaTupleConditionTest.java
new file mode 100644
index 000000000000..af38265d8583
--- /dev/null
+++ 
b/components/camel-openfga/src/test/java/org/apache/camel/component/openfga/OpenFgaTupleConditionTest.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openfga;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+import dev.openfga.sdk.api.client.OpenFgaClient;
+import dev.openfga.sdk.api.client.model.ClientReadChangesRequest;
+import dev.openfga.sdk.api.client.model.ClientReadChangesResponse;
+import dev.openfga.sdk.api.client.model.ClientReadRequest;
+import dev.openfga.sdk.api.client.model.ClientReadResponse;
+import dev.openfga.sdk.api.client.model.ClientRelationshipCondition;
+import dev.openfga.sdk.api.client.model.ClientTupleKey;
+import dev.openfga.sdk.api.client.model.ClientTupleKeyWithoutCondition;
+import dev.openfga.sdk.api.client.model.ClientWriteResponse;
+import dev.openfga.sdk.api.model.RelationshipCondition;
+import dev.openfga.sdk.api.model.Tuple;
+import dev.openfga.sdk.api.model.TupleChange;
+import dev.openfga.sdk.api.model.TupleKey;
+import dev.openfga.sdk.api.model.TupleOperation;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * A relationship tuple may carry a condition (a condition name plus an 
optional context) on top of its
+ * user/relation/object identity. These tests pin that the condition is 
carried through faithfully on the write path and
+ * surfaced on the read path, while a delete stays by identity alone.
+ */
+class OpenFgaTupleConditionTest extends CamelTestSupport {
+
+    private static final String STORE = "01HQMVAJXYZ0000000000000";
+    private static final String BASE = "?openFgaClient=#fgaClient&storeId=" + 
STORE;
+
+    @BindToRegistry("fgaClient")
+    private final OpenFgaClient client = mock(OpenFgaClient.class);
+
+    private static ClientRelationshipCondition clientCondition() {
+        return new 
ClientRelationshipCondition().name("in_allowed_region").context(Map.of("region",
 "emea"));
+    }
+
+    @Test
+    void writeTuplesPreservesTheConditionOnASingleClientTupleKey() throws 
Exception {
+        
when(client.writeTuples(anyList())).thenReturn(CompletableFuture.completedFuture(mock(ClientWriteResponse.class)));
+
+        Exchange out = template.request("openfga:writeTuples" + BASE, e -> 
e.getMessage().setBody(
+                new 
ClientTupleKey().user("user:maya").relation("viewer")._object("document:roadmap")
+                        .condition(clientCondition())));
+
+        assertThat(out.getException()).isNull();
+        ArgumentCaptor<List<ClientTupleKey>> captor = ArgumentCaptor.captor();
+        verify(client).writeTuples(captor.capture());
+        assertThat(captor.getValue()).singleElement()
+                .satisfies(key -> {
+                    assertThat(key.getUser()).isEqualTo("user:maya");
+                    assertThat(key.getRelation()).isEqualTo("viewer");
+                    assertThat(key.getObject()).isEqualTo("document:roadmap");
+                    assertThat(key.getCondition()).isNotNull();
+                    
assertThat(key.getCondition().getName()).isEqualTo("in_allowed_region");
+                    
assertThat(key.getCondition().getContext()).isEqualTo(Map.of("region", "emea"));
+                });
+    }
+
+    @Test
+    void writeTuplesPreservesConditionsAcrossACollection() throws Exception {
+        
when(client.writeTuples(anyList())).thenReturn(CompletableFuture.completedFuture(mock(ClientWriteResponse.class)));
+
+        Exchange out = template.request("openfga:writeTuples" + BASE, e -> 
e.getMessage().setBody(List.of(
+                new 
ClientTupleKey().user("user:maya").relation("viewer")._object("document:roadmap")
+                        .condition(clientCondition()),
+                new 
ClientTupleKey().user("user:leo").relation("editor")._object("document:ledger"))));
+
+        assertThat(out.getException()).isNull();
+        ArgumentCaptor<List<ClientTupleKey>> captor = ArgumentCaptor.captor();
+        verify(client).writeTuples(captor.capture());
+        assertThat(captor.getValue()).hasSize(2);
+        // the conditioned tuple keeps its condition, and the plain one stays 
unconditioned
+        assertThat(captor.getValue().get(0).getCondition()).isNotNull()
+                .satisfies(condition -> 
assertThat(condition.getName()).isEqualTo("in_allowed_region"));
+        assertThat(captor.getValue().get(1).getCondition()).isNull();
+    }
+
+    @Test
+    void writeTuplesPreservesAConditionGivenAsAMap() throws Exception {
+        
when(client.writeTuples(anyList())).thenReturn(CompletableFuture.completedFuture(mock(ClientWriteResponse.class)));
+
+        Exchange out = template.request("openfga:writeTuples" + BASE, e -> 
e.getMessage().setBody(Map.of(
+                "user", "user:maya", "relation", "viewer", "object", 
"document:roadmap",
+                "condition", Map.of("name", "in_allowed_region", "context", 
Map.of("region", "emea")))));
+
+        assertThat(out.getException()).isNull();
+        ArgumentCaptor<List<ClientTupleKey>> captor = ArgumentCaptor.captor();
+        verify(client).writeTuples(captor.capture());
+        assertThat(captor.getValue()).singleElement()
+                .satisfies(key -> {
+                    assertThat(key.getCondition()).isNotNull();
+                    
assertThat(key.getCondition().getName()).isEqualTo("in_allowed_region");
+                    
assertThat(key.getCondition().getContext()).isEqualTo(Map.of("region", "emea"));
+                });
+    }
+
+    @Test
+    void writeTuplesWithoutAConditionStaysUnconditioned() throws Exception {
+        
when(client.writeTuples(anyList())).thenReturn(CompletableFuture.completedFuture(mock(ClientWriteResponse.class)));
+
+        Exchange out = template.request("openfga:writeTuples" + BASE, e -> 
e.getMessage().setBody(Map.of(
+                "user", "user:maya", "relation", "viewer", "object", 
"document:roadmap")));
+
+        assertThat(out.getException()).isNull();
+        ArgumentCaptor<List<ClientTupleKey>> captor = ArgumentCaptor.captor();
+        verify(client).writeTuples(captor.capture());
+        assertThat(captor.getValue()).singleElement()
+                .satisfies(key -> assertThat(key.getCondition()).isNull());
+    }
+
+    @Test
+    void deleteTuplesStaysIdentityOnly() throws Exception {
+        when(client.deleteTuples(anyList()))
+                
.thenReturn(CompletableFuture.completedFuture(mock(ClientWriteResponse.class)));
+
+        // a condition on the body is read but a delete is keyed by identity, 
so the delete key must not carry it
+        Exchange out = template.request("openfga:deleteTuples" + BASE, e -> 
e.getMessage().setBody(Map.of(
+                "user", "user:maya", "relation", "viewer", "object", 
"document:roadmap",
+                "condition", Map.of("name", "in_allowed_region", "context", 
Map.of("region", "emea")))));
+
+        assertThat(out.getException()).isNull();
+        
assertThat(out.getMessage().getHeader(OpenFgaConstants.DELETED_TUPLES)).isEqualTo(1);
+        ArgumentCaptor<List<ClientTupleKeyWithoutCondition>> captor = 
ArgumentCaptor.captor();
+        verify(client).deleteTuples(captor.capture());
+        assertThat(captor.getValue()).singleElement()
+                .satisfies(key -> {
+                    assertThat(key.getUser()).isEqualTo("user:maya");
+                    assertThat(key.getObject()).isEqualTo("document:roadmap");
+                    // the identity-only type, never the ClientTupleKey 
subtype that would carry a condition
+                    
assertThat(key).isExactlyInstanceOf(ClientTupleKeyWithoutCondition.class);
+                });
+    }
+
+    @Test
+    void readTuplesProjectsAStoredCondition() throws Exception {
+        ClientReadResponse response = mock(ClientReadResponse.class);
+        when(response.getTuples()).thenReturn(List.of(new Tuple()
+                .key(new 
TupleKey().user("user:maya").relation("viewer")._object("document:roadmap")
+                        .condition(new 
RelationshipCondition().name("in_allowed_region")
+                                .context(Map.of("region", "emea"))))
+                .timestamp(OffsetDateTime.parse("2026-10-01T10:00:00Z"))));
+        when(response.getContinuationToken()).thenReturn(null);
+        when(client.read(any(ClientReadRequest.class), 
any())).thenReturn(CompletableFuture.completedFuture(response));
+
+        Exchange out = template.request("openfga:readTuples" + BASE, e -> {
+        });
+
+        assertThat(out.getException()).isNull();
+        assertThat(out.getMessage().getBody(List.class)).singleElement()
+                .satisfies(entry -> {
+                    Map<?, ?> tuple = (Map<?, ?>) entry;
+                    assertThat(tuple.get("condition"))
+                            .isEqualTo(Map.of("name", "in_allowed_region", 
"context", Map.of("region", "emea")));
+                });
+    }
+
+    @Test
+    void readChangesProjectsAStoredCondition() throws Exception {
+        ClientReadChangesResponse response = 
mock(ClientReadChangesResponse.class);
+        when(response.getChanges()).thenReturn(List.of(new TupleChange()
+                .tupleKey(new 
TupleKey().user("user:maya").relation("viewer")._object("document:roadmap")
+                        .condition(new 
RelationshipCondition().name("in_allowed_region")
+                                .context(Map.of("region", "emea"))))
+                .operation(TupleOperation.WRITE)
+                .timestamp(OffsetDateTime.parse("2026-10-01T10:00:00Z"))));
+        when(response.getContinuationToken()).thenReturn(null);
+        when(client.readChanges(any(ClientReadChangesRequest.class), any()))
+                .thenReturn(CompletableFuture.completedFuture(response));
+
+        Exchange out = template.request("openfga:readChanges" + BASE, e -> {
+        });
+
+        assertThat(out.getException()).isNull();
+        assertThat(out.getMessage().getBody(List.class)).singleElement()
+                .satisfies(entry -> {
+                    Map<?, ?> change = (Map<?, ?>) entry;
+                    assertThat(change.get("operation")).isEqualTo("WRITE");
+                    assertThat(change.get("condition"))
+                            .isEqualTo(Map.of("name", "in_allowed_region", 
"context", Map.of("region", "emea")));
+                });
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index d664c93c3d5f..ec03b6400f3f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -105,6 +105,17 @@ Starting in 4.24, parameter values provided via the 
`CamelJpaParameters` header
 
 Routes that previously relied on evaluating Simple expressions within the 
`CamelJpaParameters` header should evaluate those expressions in the route 
before setting the header value.
 
+=== camel-openfga - writeTuples and the read operations carry a relationship 
condition
+
+`writeTuples` now honours a relationship condition set on a tuple in the 
message body: a `ClientTupleKey` built with
+`.condition(..)`, or a `Map` whose `condition` entry is a map of a `name` and 
an optional `context`. Before, only the
+`user`, `relation` and `object` of a tuple were written and any condition was 
dropped, so a conditioned grant was
+stored unconditioned. A tuple with no condition is still written 
unconditioned, and `deleteTuples` still deletes by
+identity and ignores a condition.
+
+`readTuples` and `readChanges` now add a `condition` entry (a map of `name` 
and `context`) to each result map when the
+stored tuple has one, so a condition can be read back and round-tripped into a 
write.
+
 === camel-mcp-server - error replies on /mcp are JSON-RPC error responses
 
 With the embedded HTTP server (Camel Main and Camel JBang), a request on 
`/mcp` that fails before it reaches the MCP

Reply via email to