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