From 2e72c9f403256ef65217627d503136b7f3d7fab0 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 29 Sep 2026 20:43:24 +0800 Subject: [PATCH 1/4] feat: govern graph mutations before persistence --- README.md | 11 +- ...java-data-service-provider-architecture.MD | 17 +- .../java/io/teaql/core/MutationDecision.java | 25 ++ .../java/io/teaql/core/MutationExecutor.java | 2 +- .../java/io/teaql/core/MutationOperation.java | 19 ++ .../io/teaql/core/MutationOperationKind.java | 3 + .../main/java/io/teaql/core/MutationPlan.java | 18 ++ .../java/io/teaql/core/MutationPolicy.java | 6 + .../io/teaql/core/MutationPolicyApproval.java | 13 + .../core/MutationPolicyApprovalProvider.java | 10 + .../io/teaql/core/MutationPolicyIdentity.java | 11 + .../io/teaql/core/MutationPolicyRegistry.java | 10 + .../java/io/teaql/core/MutationRequest.java | 4 - .../io/teaql/core/PersistenceMutation.java | 4 + .../main/java/io/teaql/core/QueryPolicy.java | 6 + .../java/io/teaql/core/RequestPolicy.java | 41 ---- .../sql/SqlDataServiceExecutor.java | 4 +- .../sql/SqlDataServiceExecutorTest.java | 4 +- .../runtime/DefaultTextRuntimeLogSink.java | 13 + ...st.java => EntityPersistenceMutation.java} | 6 +- .../runtime/MutationGovernanceEvent.java | 4 + .../runtime/MutationGovernanceSnapshot.java | 18 ++ .../runtime/MutationOperationSummary.java | 12 + .../runtime/MutationPolicyApprovalStatus.java | 7 + .../teaql/runtime/MutationPolicySource.java | 6 + .../java/io/teaql/runtime/RawAuditEvent.java | 3 +- .../java/io/teaql/runtime/RuntimeLogSink.java | 7 + .../java/io/teaql/runtime/SafeAuditEvent.java | 3 +- .../java/io/teaql/runtime/TeaQLRuntime.java | 211 +++++++++++++--- .../runtime/memory/MemoryDataService.java | 12 +- .../io/teaql/runtime/TeaQLRuntimeTest.java | 225 ++++++++++++++++-- .../sql/portable/PortableSQLDataService.java | 16 +- .../java/io/teaql/tfp/TfpEndpointHandler.java | 41 ++-- .../io/teaql/tfp/TfpConformanceServer.java | 42 +++- .../io/teaql/tfp/TfpEndpointPolicyTest.java | 91 +++++-- .../teaql/tfp/TfpEndpointTelemetryTest.java | 23 +- 36 files changed, 775 insertions(+), 173 deletions(-) create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationDecision.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationOperation.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationOperationKind.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationPlan.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationPolicy.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationPolicyApproval.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationPolicyApprovalProvider.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationPolicyIdentity.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationPolicyRegistry.java delete mode 100644 teaql-core/src/main/java/io/teaql/core/MutationRequest.java create mode 100644 teaql-core/src/main/java/io/teaql/core/PersistenceMutation.java create mode 100644 teaql-core/src/main/java/io/teaql/core/QueryPolicy.java delete mode 100644 teaql-core/src/main/java/io/teaql/core/RequestPolicy.java rename teaql-runtime/src/main/java/io/teaql/runtime/{DefaultMutationRequest.java => EntityPersistenceMutation.java} (66%) create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/MutationGovernanceEvent.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/MutationGovernanceSnapshot.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/MutationOperationSummary.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/MutationPolicyApprovalStatus.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/MutationPolicySource.java diff --git a/README.md b/README.md index 393daa37..ad351a67 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,8 @@ TeaQL applies five safeguards to application operations: which carries identity, trace, and runtime capabilities. 2. **Declared intent** — reads require `.comment(...).purpose(...)`; writes use `.auditAs(...)` before an execution terminal becomes available. -3. **Policy gates** — `RequestPolicy` can inspect or reject select, insert, - update, delete, and recover operations. +3. **Policy gates** — `QueryPolicy` reviews reads; `MutationPolicy` reviews one + immutable graph-level `MutationPlan` before the first provider write. 4. **Explicit capabilities** — optional operations such as HTTP tools, dynamic fields, and business ID generation are supplied through dedicated modules and registered runtime capabilities. @@ -110,9 +110,10 @@ Mutations declare an audit action: task.auditAs("Move task to Done").save(userContext); ``` -Applications can replace runtime services such as `RequestPolicy`, -`RuntimeLogSink`, `DataServiceRegistry`, `InternalIdGenerationService`, and -`EntityMetaFactory` in their integration layer. +Applications can replace runtime services such as `QueryPolicy`, the +`MutationPolicyRegistry`, `MutationPolicyApprovalProvider`, `RuntimeLogSink`, +`DataServiceRegistry`, `InternalIdGenerationService`, and `EntityMetaFactory` +in their integration layer. Query and Mutation execution logs are enabled by default. The built-in default sink is safe for ordinary operator output: it includes intent, trace, elapsed diff --git a/docs/2026-06-12-java-data-service-provider-architecture.MD b/docs/2026-06-12-java-data-service-provider-architecture.MD index 00de8e51..e809485c 100644 --- a/docs/2026-06-12-java-data-service-provider-architecture.MD +++ b/docs/2026-06-12-java-data-service-provider-architecture.MD @@ -91,7 +91,8 @@ Core request-model concepts: ```text SearchRequest -MutationRequest +MutationPlan +PersistenceMutation AggregationRequest Criteria Expression @@ -113,7 +114,8 @@ TransactionExecutor SchemaExecutor DataServiceCapabilities ExecutionMetadata -RequestPolicy +QueryPolicy +MutationPolicy InternalIdGenerationService RuntimeLogSink TransactionCallback @@ -310,7 +312,8 @@ These interfaces and contracts belong in `teaql-core`: ```text UserContext -RequestPolicy +QueryPolicy +MutationPolicy InternalIdGenerationService DataServiceRegistry DataServiceExecutor @@ -398,7 +401,7 @@ It accepts and returns TeaQL concepts: ```text QueryRequest -MutationRequest +PersistenceMutation AggregationRequest SchemaRequest TransactionCallback @@ -532,7 +535,7 @@ public interface QueryExecutor extends DataServiceExecutor { ```java public interface MutationExecutor extends DataServiceExecutor { - MutationResult mutate(UserContext ctx, MutationRequest request); + MutationResult mutate(UserContext ctx, PersistenceMutation mutation); } ``` @@ -682,7 +685,7 @@ TeaQLRuntime runtime = TeaQLRuntime.builder() .metadata(entityMetaFactory) .dataService("sql", sqlDataService) .dataService("memory", memoryDataService) - .requestPolicy(new PurposeRequestPolicy()) + .queryPolicy(new PurposeQueryPolicy()) .logSink(LogManager.getInstance()) .build(); @@ -832,7 +835,7 @@ Target module layout: teaql-core TeaQL world model and runtime contracts. Includes Bean, Entity, EntityProperty, Relation, metadata descriptors, - request models, UserContext, RequestPolicy, Data Service contracts, + request models, UserContext, QueryPolicy, MutationPolicy, Data Service contracts, InternalIdGenerationService, transaction contracts, and execution log contracts. No Spring, JDBC, SQL dialect, Android, GraphQL, web rendering, or concrete backend implementation. diff --git a/teaql-core/src/main/java/io/teaql/core/MutationDecision.java b/teaql-core/src/main/java/io/teaql/core/MutationDecision.java new file mode 100644 index 00000000..2aefa70a --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationDecision.java @@ -0,0 +1,25 @@ +package io.teaql.core; + +import java.util.List; + +public record MutationDecision( + Verdict verdict, String code, String message, List fieldPaths) { + public enum Verdict { ALLOW, DENY } + + public MutationDecision { + fieldPaths = List.copyOf(fieldPaths == null ? List.of() : fieldPaths); + if (verdict == Verdict.DENY && (code == null || code.isBlank())) { + throw new IllegalArgumentException("A denied mutation decision requires a stable code"); + } + } + + public static MutationDecision allow() { + return new MutationDecision(Verdict.ALLOW, null, null, List.of()); + } + + public static MutationDecision deny(String code, String message, List fieldPaths) { + return new MutationDecision(Verdict.DENY, code, message, fieldPaths); + } + + public boolean allowed() { return verdict == Verdict.ALLOW; } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationExecutor.java b/teaql-core/src/main/java/io/teaql/core/MutationExecutor.java index 738cc731..f0fee9c0 100644 --- a/teaql-core/src/main/java/io/teaql/core/MutationExecutor.java +++ b/teaql-core/src/main/java/io/teaql/core/MutationExecutor.java @@ -1,5 +1,5 @@ package io.teaql.core; public interface MutationExecutor extends DataServiceExecutor { - MutationResult mutate(UserContext context, MutationRequest request); + MutationResult mutate(UserContext context, PersistenceMutation mutation); } diff --git a/teaql-core/src/main/java/io/teaql/core/MutationOperation.java b/teaql-core/src/main/java/io/teaql/core/MutationOperation.java new file mode 100644 index 00000000..5813ba8c --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationOperation.java @@ -0,0 +1,19 @@ +package io.teaql.core; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public record MutationOperation( + MutationOperationKind kind, + EntityKey entity, + Long originalVersion, + Map changedValues) { + public MutationOperation { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(entity, "entity"); + changedValues = Collections.unmodifiableMap(new LinkedHashMap<>( + changedValues == null ? Map.of() : changedValues)); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationOperationKind.java b/teaql-core/src/main/java/io/teaql/core/MutationOperationKind.java new file mode 100644 index 00000000..e2f0decd --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationOperationKind.java @@ -0,0 +1,3 @@ +package io.teaql.core; + +public enum MutationOperationKind { CREATE, UPDATE, DELETE, RECOVER } diff --git a/teaql-core/src/main/java/io/teaql/core/MutationPlan.java b/teaql-core/src/main/java/io/teaql/core/MutationPlan.java new file mode 100644 index 00000000..ddca9bc5 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationPlan.java @@ -0,0 +1,18 @@ +package io.teaql.core; + +import java.util.List; +import java.util.Objects; + +public record MutationPlan( + String executionId, + String requestKey, + String rootEntityType, + String auditReason, + List operations) { + public MutationPlan { + Objects.requireNonNull(executionId, "executionId"); + Objects.requireNonNull(requestKey, "requestKey"); + Objects.requireNonNull(rootEntityType, "rootEntityType"); + operations = List.copyOf(operations == null ? List.of() : operations); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationPolicy.java b/teaql-core/src/main/java/io/teaql/core/MutationPolicy.java new file mode 100644 index 00000000..d5db902c --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationPolicy.java @@ -0,0 +1,6 @@ +package io.teaql.core; + +public interface MutationPolicy { + MutationPolicyIdentity identity(); + MutationDecision review(UserContext context, MutationPlan plan); +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationPolicyApproval.java b/teaql-core/src/main/java/io/teaql/core/MutationPolicyApproval.java new file mode 100644 index 00000000..fcbbfad9 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationPolicyApproval.java @@ -0,0 +1,13 @@ +package io.teaql.core; + +import java.time.Instant; +import java.util.Objects; + +public record MutationPolicyApproval( + MutationPolicyIdentity policy, String approvedBy, Instant approvedAt) { + public MutationPolicyApproval { + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(approvedBy, "approvedBy"); + Objects.requireNonNull(approvedAt, "approvedAt"); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationPolicyApprovalProvider.java b/teaql-core/src/main/java/io/teaql/core/MutationPolicyApprovalProvider.java new file mode 100644 index 00000000..aa2f2f61 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationPolicyApprovalProvider.java @@ -0,0 +1,10 @@ +package io.teaql.core; + +import java.util.Optional; + +@FunctionalInterface +public interface MutationPolicyApprovalProvider { + Optional findApproval(MutationPolicyIdentity policy); + + static MutationPolicyApprovalProvider none() { return policy -> Optional.empty(); } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationPolicyIdentity.java b/teaql-core/src/main/java/io/teaql/core/MutationPolicyIdentity.java new file mode 100644 index 00000000..3887454d --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationPolicyIdentity.java @@ -0,0 +1,11 @@ +package io.teaql.core; + +import java.util.Objects; + +public record MutationPolicyIdentity(String id, String version, String fingerprint) { + public MutationPolicyIdentity { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(version, "version"); + Objects.requireNonNull(fingerprint, "fingerprint"); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationPolicyRegistry.java b/teaql-core/src/main/java/io/teaql/core/MutationPolicyRegistry.java new file mode 100644 index 00000000..5bb84bba --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationPolicyRegistry.java @@ -0,0 +1,10 @@ +package io.teaql.core; + +import java.util.Optional; + +@FunctionalInterface +public interface MutationPolicyRegistry { + Optional resolve(String requestKey); + + static MutationPolicyRegistry empty() { return requestKey -> Optional.empty(); } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationRequest.java b/teaql-core/src/main/java/io/teaql/core/MutationRequest.java deleted file mode 100644 index 63390dff..00000000 --- a/teaql-core/src/main/java/io/teaql/core/MutationRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.teaql.core; - -public interface MutationRequest { -} diff --git a/teaql-core/src/main/java/io/teaql/core/PersistenceMutation.java b/teaql-core/src/main/java/io/teaql/core/PersistenceMutation.java new file mode 100644 index 00000000..07becd1d --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/PersistenceMutation.java @@ -0,0 +1,4 @@ +package io.teaql.core; + +/** One low-level provider write, distinct from a complete {@link MutationPlan}. */ +public interface PersistenceMutation {} diff --git a/teaql-core/src/main/java/io/teaql/core/QueryPolicy.java b/teaql-core/src/main/java/io/teaql/core/QueryPolicy.java new file mode 100644 index 00000000..1a5c154e --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/QueryPolicy.java @@ -0,0 +1,6 @@ +package io.teaql.core; + +/** Reviews a query before it is executed. */ +public interface QueryPolicy { + default void enforceSelect(UserContext context, SearchRequest query) {} +} diff --git a/teaql-core/src/main/java/io/teaql/core/RequestPolicy.java b/teaql-core/src/main/java/io/teaql/core/RequestPolicy.java deleted file mode 100644 index 47532f8e..00000000 --- a/teaql-core/src/main/java/io/teaql/core/RequestPolicy.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.teaql.core; - -/** - * Request policy interface, called before each operation. - * Can modify queries/commands or reject execution. - * - * Design aligned with teaql-rs RequestPolicy trait. - */ -public interface RequestPolicy { - - /** - * Policy check before query execution. - * Can reject if comment or purpose is missing. - */ - default void enforceSelect(UserContext context, SearchRequest query) { - } - - /** - * Policy check before insert. - */ - default void enforceInsert(UserContext context, Entity entity) { - } - - /** - * Policy check before update. - */ - default void enforceUpdate(UserContext context, Entity entity) { - } - - /** - * Policy check before delete. - */ - default void enforceDelete(UserContext context, Entity entity) { - } - - /** - * Policy check before recover. - */ - default void enforceRecover(UserContext context, Entity entity) { - } -} diff --git a/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDataServiceExecutor.java b/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDataServiceExecutor.java index ba3a0bec..54301ab1 100644 --- a/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDataServiceExecutor.java +++ b/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDataServiceExecutor.java @@ -3,7 +3,7 @@ import io.teaql.core.UserContext; import io.teaql.core.DataServiceCapabilities; import io.teaql.core.MutationExecutor; -import io.teaql.core.MutationRequest; +import io.teaql.core.PersistenceMutation; import io.teaql.core.MutationResult; import io.teaql.core.QueryExecutor; import io.teaql.core.QueryRequest; @@ -71,7 +71,7 @@ public java.util.stream.Stream queryForStrea } @Override - public MutationResult mutate(UserContext context, MutationRequest request) { + public MutationResult mutate(UserContext context, PersistenceMutation request) { return getPortableService(context).mutate(context, request); } diff --git a/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlDataServiceExecutorTest.java b/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlDataServiceExecutorTest.java index de972851..27c55f07 100644 --- a/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlDataServiceExecutorTest.java +++ b/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlDataServiceExecutorTest.java @@ -1,7 +1,7 @@ package io.teaql.dataservice.sql; import io.teaql.core.UserContext; -import io.teaql.core.MutationRequest; +import io.teaql.core.PersistenceMutation; import io.teaql.core.QueryRequest; import io.teaql.core.BaseEntity; import io.teaql.core.BaseRequest; @@ -58,7 +58,7 @@ public void testQueryPlaceholder() { public void testMutatePlaceholder() { SqlDataServiceExecutor executor = new SqlDataServiceExecutor("sql", new MockSqlExecutionAdapter()); Assert.assertThrows(io.teaql.core.TeaQLRuntimeException.class, () -> { - executor.mutate(null, new MutationRequest() {}); + executor.mutate(null, new PersistenceMutation() {}); }); } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java index 53210b01..6eada02d 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java @@ -38,6 +38,19 @@ public void writeExecutionLog(UserContext context, ExecutionMetadata metadata) { nullToEmpty(metadata.getDebugQuery())); } + @Override + public void writeMutationGovernanceEvent( + UserContext context, MutationGovernanceEvent event) { + if (!event.firstOccurrence()) return; + output.printf( + "[TeaQL MUTATION GOVERNANCE][%s] request=%s execution=%s policy=%s approval=%s%n", + event.warningCode(), + event.snapshot().requestKey(), + event.snapshot().executionId(), + event.snapshot().policy() == null ? "none" : event.snapshot().policy().id(), + event.snapshot().approvalStatus()); + } + protected static String formatTrace(List nodes) { if (nodes == null) return "[]"; return "[" + IntStream.range(0, nodes.size()) diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultMutationRequest.java b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java similarity index 66% rename from teaql-runtime/src/main/java/io/teaql/runtime/DefaultMutationRequest.java rename to teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java index 65ea6b2c..ecd7ca81 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultMutationRequest.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java @@ -1,15 +1,15 @@ package io.teaql.runtime; import io.teaql.core.Entity; -import io.teaql.core.MutationRequest; +import io.teaql.core.PersistenceMutation; -public class DefaultMutationRequest implements MutationRequest { +public class EntityPersistenceMutation implements PersistenceMutation { public enum Action { SAVE, DELETE } private final Entity entity; private final Action action; - public DefaultMutationRequest(Entity entity, Action action) { + public EntityPersistenceMutation(Entity entity, Action action) { this.entity = entity; this.action = action; } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/MutationGovernanceEvent.java b/teaql-runtime/src/main/java/io/teaql/runtime/MutationGovernanceEvent.java new file mode 100644 index 00000000..c257704c --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/MutationGovernanceEvent.java @@ -0,0 +1,4 @@ +package io.teaql.runtime; + +public record MutationGovernanceEvent( + MutationGovernanceSnapshot snapshot, String warningCode, boolean firstOccurrence) {} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/MutationGovernanceSnapshot.java b/teaql-runtime/src/main/java/io/teaql/runtime/MutationGovernanceSnapshot.java new file mode 100644 index 00000000..0094b7b3 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/MutationGovernanceSnapshot.java @@ -0,0 +1,18 @@ +package io.teaql.runtime; + +import io.teaql.core.MutationPolicyIdentity; +import java.util.List; + +public record MutationGovernanceSnapshot( + String executionId, + String requestKey, + MutationPolicySource source, + MutationPolicyIdentity policy, + MutationPolicyApprovalStatus approvalStatus, + List warningCodes, + List operations) { + public MutationGovernanceSnapshot { + warningCodes = List.copyOf(warningCodes == null ? List.of() : warningCodes); + operations = List.copyOf(operations == null ? List.of() : operations); + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/MutationOperationSummary.java b/teaql-runtime/src/main/java/io/teaql/runtime/MutationOperationSummary.java new file mode 100644 index 00000000..7e4c9b63 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/MutationOperationSummary.java @@ -0,0 +1,12 @@ +package io.teaql.runtime; + +import io.teaql.core.EntityKey; +import io.teaql.core.MutationOperationKind; +import java.util.List; + +public record MutationOperationSummary( + MutationOperationKind kind, EntityKey entity, List changedFields) { + public MutationOperationSummary { + changedFields = List.copyOf(changedFields == null ? List.of() : changedFields); + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/MutationPolicyApprovalStatus.java b/teaql-runtime/src/main/java/io/teaql/runtime/MutationPolicyApprovalStatus.java new file mode 100644 index 00000000..ca37c0ba --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/MutationPolicyApprovalStatus.java @@ -0,0 +1,7 @@ +package io.teaql.runtime; + +public enum MutationPolicyApprovalStatus { + NOT_APPLICABLE, + MISSING, + APPROVED +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/MutationPolicySource.java b/teaql-runtime/src/main/java/io/teaql/runtime/MutationPolicySource.java new file mode 100644 index 00000000..5147b5b1 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/MutationPolicySource.java @@ -0,0 +1,6 @@ +package io.teaql.runtime; + +public enum MutationPolicySource { + GENERATED_DEFAULT, + CUSTOMER +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/RawAuditEvent.java b/teaql-runtime/src/main/java/io/teaql/runtime/RawAuditEvent.java index 4acc3037..ff205b30 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/RawAuditEvent.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/RawAuditEvent.java @@ -14,7 +14,8 @@ public record RawAuditEvent( String category, String reason, Long resultingVersion, - Instant occurredAt) { + Instant occurredAt, + MutationGovernanceSnapshot governance) { public RawAuditEvent { changes = List.copyOf(changes == null ? List.of() : changes); diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/RuntimeLogSink.java b/teaql-runtime/src/main/java/io/teaql/runtime/RuntimeLogSink.java index 67ecb4e2..16f5742a 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/RuntimeLogSink.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/RuntimeLogSink.java @@ -16,4 +16,11 @@ default boolean requiresSensitiveSqlData() { } default void writeAuditEvent(UserContext context, RawAuditEvent event) {} + + /** + * Receives structured mutation-governance evidence. Implementations should + * print warnings only when {@code firstOccurrence} is true. + */ + default void writeMutationGovernanceEvent( + UserContext context, MutationGovernanceEvent event) {} } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/SafeAuditEvent.java b/teaql-runtime/src/main/java/io/teaql/runtime/SafeAuditEvent.java index 6920852f..75aacd81 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/SafeAuditEvent.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/SafeAuditEvent.java @@ -8,7 +8,8 @@ public record SafeAuditEvent( String entityType, Object entityId, List fields, - List traceChain) { + List traceChain, + MutationGovernanceSnapshot governance) { public SafeAuditEvent { fields = List.copyOf(fields == null ? List.of() : fields); diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index 249d2bc3..2d3a5f9a 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -22,7 +22,9 @@ public class TeaQLRuntime { private final EntityMetaFactory metadata; private final DataServiceRegistry registry; - private final RequestPolicy requestPolicy; + private final QueryPolicy queryPolicy; + private final MutationPolicyRegistry mutationPolicyRegistry; + private final MutationPolicyApprovalProvider mutationPolicyApprovalProvider; private final InternalIdGenerationService idGenerationService; private final RuntimeLogSink logSink; private final boolean queryExecutionLoggingEnabled; @@ -37,11 +39,15 @@ public class TeaQLRuntime { private final Map> checkers = new java.util.concurrent.ConcurrentHashMap<>(); private final List generatedBootstraps = new java.util.concurrent.CopyOnWriteArrayList<>(); + private final Set emittedMutationGovernanceWarnings = + java.util.concurrent.ConcurrentHashMap.newKeySet(); private TeaQLRuntime(Builder builder) { this.metadata = builder.metadata; this.registry = builder.registry != null ? builder.registry : new DefaultDataServiceRegistry(); - this.requestPolicy = builder.requestPolicy; + this.queryPolicy = builder.queryPolicy; + this.mutationPolicyRegistry = builder.mutationPolicyRegistry; + this.mutationPolicyApprovalProvider = builder.mutationPolicyApprovalProvider; this.idGenerationService = builder.idGenerationService; this.logSink = builder.logSink; this.queryExecutionLoggingEnabled = builder.queryExecutionLoggingEnabled; @@ -69,8 +75,16 @@ public DataServiceRegistry getRegistry() { return registry; } - public RequestPolicy getRequestPolicy() { - return requestPolicy; + public QueryPolicy getQueryPolicy() { + return queryPolicy; + } + + public MutationPolicyRegistry getMutationPolicyRegistry() { + return mutationPolicyRegistry; + } + + public MutationPolicyApprovalProvider getMutationPolicyApprovalProvider() { + return mutationPolicyApprovalProvider; } public InternalIdGenerationService getIdGenerationService() { @@ -156,8 +170,8 @@ public SmartList executeForList(UserContext context, Searc throw new TeaQLRuntimeException("[PURPOSE REQUIRED] Missing .purpose() on query execution."); } enforceMaterializedLimit(request, request.hardLimit()); - if (requestPolicy != null) { - requestPolicy.enforceSelect(context, request); + if (queryPolicy != null) { + queryPolicy.enforceSelect(context, request); } boolean pushedComment = false; boolean pushedPurpose = false; @@ -297,8 +311,8 @@ public SmartList internalExecuteForList( "[INTERNAL QUERY CONTEXT REQUIRED] Nested query has no authorized root trace."); } enforceMaterializedLimit(request, SearchRequest.DEFAULT_HARD_LIMIT); - if (requestPolicy != null) { - requestPolicy.enforceSelect(context, request); + if (queryPolicy != null) { + queryPolicy.enforceSelect(context, request); } context.pushTrace(TraceKind.RELATION, request.getTypeName(), request.getTypeName()); try { @@ -370,8 +384,8 @@ public AggregationResult aggregation(UserContext context, Sea if (request.purpose() == null || request.purpose().trim().isEmpty()) { throw new TeaQLRuntimeException("[PURPOSE REQUIRED] Missing .purpose() on aggregation."); } - if (requestPolicy != null) { - requestPolicy.enforceSelect(context, request); + if (queryPolicy != null) { + queryPolicy.enforceSelect(context, request); } boolean pushedComment = false; boolean pushedPurpose = false; @@ -487,14 +501,17 @@ public void saveGraph(UserContext context, Entity entity) { new PersistenceState( value.getVersion(), value.get$status(), value.isPropertyLoaded(BaseEntity.VERSION_PROPERTY)))); + MutationPlan mutationPlan = buildMutationPlan(entity, entityMutationLedger, realEntities); + MutationGovernanceSnapshot governance = reviewMutationPlan(context, mutationPlan); List completed; try { if (mutationExecutor instanceof TransactionExecutor transactionExecutor) { completed = transactionExecutor.executeInTransaction(context, () -> - executeLedgerPlan(context, entityMutationLedger, mutationExecutor, realEntities)); + executeLedgerPlan(context, entityMutationLedger, mutationExecutor, + realEntities, governance)); } else { completed = executeLedgerPlan( - context, entityMutationLedger, mutationExecutor, realEntities); + context, entityMutationLedger, mutationExecutor, realEntities, governance); } } catch (RuntimeException | Error failure) { restoreGraphPersistenceState( @@ -677,11 +694,121 @@ private static BaseEntity preferMaterializedEntity( return existing; } + private MutationPlan buildMutationPlan( + Entity rootEntity, + EntityMutationLedger ledger, + Map realEntities) { + EntityChangeSet changeSet = ledger.currentChangeSet(); + Set deleted = ledger.deletedKeys(); + Set created = ledger.newKeys(); + Set keys = new TreeSet<>(); + keys.addAll(changeSet.changes().keySet()); + keys.addAll(deleted); + + List operations = new ArrayList<>(); + for (EntityKey key : keys) { + BaseEntity target = realEntities.get(key); + MutationOperationKind kind; + Map changes; + if (deleted.contains(key)) { + kind = MutationOperationKind.DELETE; + changes = Map.of(); + } else { + changes = changeSet.changes().getOrDefault(key, Map.of()); + if (created.contains(key) || key.id() == null) { + kind = MutationOperationKind.CREATE; + } else if (target != null && target.recoverItem()) { + kind = MutationOperationKind.RECOVER; + } else { + kind = MutationOperationKind.UPDATE; + } + } + Long originalVersion = ledger.getOriginalVersion(key); + if (originalVersion == null && target != null) originalVersion = target.getVersion(); + operations.add(new MutationOperation(kind, key, originalVersion, changes)); + } + return new MutationPlan( + UUID.randomUUID().toString(), + rootEntity.typeName() + ".saveGraph", + rootEntity.typeName(), + rootEntity.getComment(), + operations); + } + + private MutationGovernanceSnapshot reviewMutationPlan( + UserContext context, MutationPlan plan) { + Optional resolved = mutationPolicyRegistry.resolve(plan.requestKey()); + MutationPolicySource source; + MutationPolicyIdentity identity = null; + MutationPolicyApprovalStatus approvalStatus; + List warnings = new ArrayList<>(); + + if (resolved.isEmpty()) { + source = MutationPolicySource.GENERATED_DEFAULT; + approvalStatus = MutationPolicyApprovalStatus.NOT_APPLICABLE; + warnings.add("MUTATION-POLICY-001"); + } else { + source = MutationPolicySource.CUSTOMER; + MutationPolicy policy = resolved.get(); + identity = Objects.requireNonNull(policy.identity(), "MutationPolicy.identity()"); + MutationDecision decision = Objects.requireNonNull( + policy.review(context, plan), "MutationPolicy.review()"); + if (!decision.allowed()) { + throw new TeaQLRuntimeException( + "[MUTATION POLICY DENIED] " + decision.code() + ": " + + (decision.message() == null ? "mutation rejected" : decision.message())); + } + boolean approved = mutationPolicyApprovalProvider.findApproval(identity) + .map(MutationPolicyApproval::policy) + .filter(identity::equals) + .isPresent(); + approvalStatus = approved + ? MutationPolicyApprovalStatus.APPROVED + : MutationPolicyApprovalStatus.MISSING; + if (!approved) warnings.add("MUTATION-POLICY-002"); + } + + List operationSummaries = plan.operations().stream() + .map(operation -> new MutationOperationSummary( + operation.kind(), + operation.entity(), + new ArrayList<>(operation.changedValues().keySet()))) + .toList(); + MutationGovernanceSnapshot snapshot = new MutationGovernanceSnapshot( + plan.executionId(), plan.requestKey(), source, identity, approvalStatus, + warnings, operationSummaries); + for (String warning : warnings) { + emitMutationGovernanceWarning(context, snapshot, warning); + } + return snapshot; + } + + private void emitMutationGovernanceWarning( + UserContext context, + MutationGovernanceSnapshot snapshot, + String warningCode) { + if (logSink == null) return; + String identity = snapshot.policy() == null + ? "none" + : snapshot.policy().id() + ":" + snapshot.policy().version() + + ":" + snapshot.policy().fingerprint(); + boolean first = emittedMutationGovernanceWarnings.add( + snapshot.requestKey() + "|" + identity + "|" + warningCode); + try { + logSink.writeMutationGovernanceEvent( + context, new MutationGovernanceEvent(snapshot, warningCode, first)); + } catch (RuntimeException | Error ignored) { + // Governance-warning delivery is fail-open. The retained audit + // event still contains the complete snapshot. + } + } + private List executeLedgerPlan( UserContext context, EntityMutationLedger root, MutationExecutor mutationExecutor, - Map realEntities) { + Map realEntities, + MutationGovernanceSnapshot governance) { List completed = new ArrayList<>(); EntityChangeSet changeSet = root.currentChangeSet(); Set deletedKeys = root.deletedKeys(); @@ -705,13 +832,13 @@ private List executeLedgerPlan( deleteEntity.markForDeletion(); if (root.getComment() != null) deleteEntity.setComment(root.getComment()); - DefaultMutationRequest mutationRequest = new DefaultMutationRequest( - deleteEntity, DefaultMutationRequest.Action.DELETE); + EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( + deleteEntity, EntityPersistenceMutation.Action.DELETE); MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, key.entity(), "delete"); completed.add(new PendingMutation( descriptor, target == null ? deleteEntity : target, result, - MutationAuditKind.DELETED, Collections.emptyMap())); + MutationAuditKind.DELETED, Collections.emptyMap(), governance)); } // 2. Group changes @@ -753,13 +880,13 @@ private List executeLedgerPlan( } if (root.getComment() != null) entity.setComment(root.getComment()); - DefaultMutationRequest mutationRequest = new DefaultMutationRequest( - entity, DefaultMutationRequest.Action.SAVE); + EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( + entity, EntityPersistenceMutation.Action.SAVE); MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, entityName, "save"); completed.add(new PendingMutation( descriptor, target == null ? entity : target, result, - MutationAuditKind.CREATED, snapshotChanges(changes))); + MutationAuditKind.CREATED, snapshotChanges(changes), governance)); } } @@ -788,8 +915,8 @@ private List executeLedgerPlan( entity.set$status(io.teaql.core.EntityStatus.UPDATED); if (root.getComment() != null) entity.setComment(root.getComment()); - DefaultMutationRequest mutationRequest = new DefaultMutationRequest( - entity, DefaultMutationRequest.Action.SAVE); + EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( + entity, EntityPersistenceMutation.Action.SAVE); MutationAuditKind auditKind = target != null && target.recoverItem() ? MutationAuditKind.RECOVERED : MutationAuditKind.UPDATED; @@ -797,7 +924,7 @@ private List executeLedgerPlan( entityName, auditKind.name().toLowerCase(Locale.ROOT)); completed.add(new PendingMutation( descriptor, target == null ? entity : target, result, - auditKind, snapshotChanges(changes))); + auditKind, snapshotChanges(changes), governance)); } } return completed; @@ -807,7 +934,8 @@ private void completeLedgerPlan(UserContext context, List compl for (PendingMutation mutation : completed) { applyPersistedEntity(mutation.descriptor(), mutation.target(), mutation.result()); emitAuditEvent( - context, mutation.target(), mutation.auditKind(), mutation.changedValues()); + context, mutation.target(), mutation.auditKind(), mutation.changedValues(), + mutation.governance()); mutation.target().clearUpdatedProperties(); } } @@ -830,7 +958,8 @@ private record PendingMutation( BaseEntity target, MutationResult result, MutationAuditKind auditKind, - Map changedValues) {} + Map changedValues, + MutationGovernanceSnapshot governance) {} private record PersistenceState( Long version, io.teaql.core.EntityStatus status, boolean versionLoaded) {} @@ -873,7 +1002,7 @@ private void applyPersistedEntity( private MutationResult mutateWithTelemetry( UserContext context, MutationExecutor executor, - MutationRequest request, + PersistenceMutation mutation, String entityType, String operation) { String provider = executor.getClass().getSimpleName(); @@ -883,7 +1012,7 @@ private MutationResult mutateWithTelemetry( "teaql.provider.operation", operation, "teaql.entity.type", entityType))); try { - MutationResult result = executor.mutate(context, request); + MutationResult result = executor.mutate(context, mutation); scope.success(); return result; } catch (RuntimeException | Error error) { @@ -896,7 +1025,8 @@ private void emitAuditEvent( UserContext context, Entity entity, MutationAuditKind kind, - Map changedValues) { + Map changedValues, + MutationGovernanceSnapshot governance) { List changes = new ArrayList<>(); if (changedValues != null) { for (Map.Entry entry : changedValues.entrySet()) { @@ -915,7 +1045,8 @@ private void emitAuditEvent( context.getAttribute(GeneratedSchemaBootstrap.AUDIT_CATEGORY_ATTRIBUTE, String.class), entity.getComment(), entity.getVersion(), - java.time.Instant.now()); + java.time.Instant.now(), + governance); RuntimeTelemetry.Scope telemetryScope = RuntimeTelemetry.startSafely(telemetry, new RuntimeTelemetry.Operation("audit", entity.typeName() + ".audit", Map.of( @@ -978,7 +1109,8 @@ private SafeAuditEvent buildSafeAuditEvent(RawAuditEvent event) { List intentValues = new ArrayList<>(sensitiveValues); if (event.entityId() != null) intentValues.add(event.entityId()); return new SafeAuditEvent( - event.kind(), event.entityType(), event.entityId(), fields, LogPrivacy.trace(event.traceChain(), intentValues)); + event.kind(), event.entityType(), event.entityId(), fields, + LogPrivacy.trace(event.traceChain(), intentValues), event.governance()); } static String maskAuditValue(String value) { @@ -1002,7 +1134,10 @@ static String limitAuditValue(String value, int maxLength) { public static class Builder { private EntityMetaFactory metadata; private DataServiceRegistry registry = new DefaultDataServiceRegistry(); - private RequestPolicy requestPolicy; + private QueryPolicy queryPolicy; + private MutationPolicyRegistry mutationPolicyRegistry = MutationPolicyRegistry.empty(); + private MutationPolicyApprovalProvider mutationPolicyApprovalProvider = + MutationPolicyApprovalProvider.none(); private InternalIdGenerationService idGenerationService; private RuntimeLogSink logSink = new DefaultTextRuntimeLogSink(); // Operation logging is default-on, while the default sink deliberately @@ -1038,8 +1173,20 @@ public Builder dataService(String name, DataServiceExecutor executor) { return this; } - public Builder requestPolicy(RequestPolicy requestPolicy) { - this.requestPolicy = requestPolicy; + public Builder queryPolicy(QueryPolicy queryPolicy) { + this.queryPolicy = queryPolicy; + return this; + } + + public Builder mutationPolicyRegistry(MutationPolicyRegistry mutationPolicyRegistry) { + this.mutationPolicyRegistry = Objects.requireNonNull(mutationPolicyRegistry); + return this; + } + + public Builder mutationPolicyApprovalProvider( + MutationPolicyApprovalProvider mutationPolicyApprovalProvider) { + this.mutationPolicyApprovalProvider = + Objects.requireNonNull(mutationPolicyApprovalProvider); return this; } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/memory/MemoryDataService.java b/teaql-runtime/src/main/java/io/teaql/runtime/memory/MemoryDataService.java index eea8d20d..6720c130 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/memory/MemoryDataService.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/memory/MemoryDataService.java @@ -75,18 +75,18 @@ public QueryResult query(UserContext context, QueryRequest request) { } @Override - public MutationResult mutate(UserContext context, MutationRequest request) { - if (!(request instanceof DefaultMutationRequest)) { - throw new TeaQLRuntimeException("Unsupported MutationRequest in MemoryDataService"); + public MutationResult mutate(UserContext context, PersistenceMutation request) { + if (!(request instanceof EntityPersistenceMutation)) { + throw new TeaQLRuntimeException("Unsupported PersistenceMutation in MemoryDataService"); } - DefaultMutationRequest mutation = (DefaultMutationRequest) request; + EntityPersistenceMutation mutation = (EntityPersistenceMutation) request; Entity entity = mutation.getEntity(); String typeName = entity.typeName(); TypeStorage storage = database.computeIfAbsent(typeName, k -> new TypeStorage(maxEntriesPerType)); storage.lock.writeLock().lock(); try { - if (mutation.getAction() == DefaultMutationRequest.Action.SAVE) { + if (mutation.getAction() == EntityPersistenceMutation.Action.SAVE) { if (entity.getId() == null) { throw new TeaQLRuntimeException("Entity ID must be allocated before save"); } @@ -95,7 +95,7 @@ public MutationResult mutate(UserContext context, MutationRequest request) { if (entity instanceof BaseEntity) { ((BaseEntity) entity).gotoNextStatus(EntityAction.PERSIST); } - } else if (mutation.getAction() == DefaultMutationRequest.Action.DELETE) { + } else if (mutation.getAction() == EntityPersistenceMutation.Action.DELETE) { storage.data.remove(entity.getId()); if (entity instanceof BaseEntity) { ((BaseEntity) entity).gotoNextStatus(EntityAction.PERSIST); diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java index cbb7f7ca..f5f5a377 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java @@ -141,12 +141,12 @@ public Stream queryForStream( } public static class RecordingMutationExecutor implements MutationExecutor { - public final List requests = new ArrayList<>(); + public final List requests = new ArrayList<>(); @Override - public MutationResult mutate(UserContext context, MutationRequest request) { - if (request instanceof DefaultMutationRequest) { - DefaultMutationRequest mutationRequest = (DefaultMutationRequest) request; + public MutationResult mutate(UserContext context, PersistenceMutation request) { + if (request instanceof EntityPersistenceMutation) { + EntityPersistenceMutation mutationRequest = (EntityPersistenceMutation) request; requests.add(mutationRequest); return new DefaultMutationResult(mutationRequest.getEntity()); } @@ -191,6 +191,7 @@ public static class PageQueryExecutor implements QueryExecutor { public static class RecordingRuntimeLogSink implements RuntimeLogSink { public final List auditEvents = new ArrayList<>(); public final List executions = new ArrayList<>(); + public final List governanceEvents = new ArrayList<>(); @Override public void writeExecutionLog(UserContext context, ExecutionMetadata metadata) { executions.add(metadata); } @@ -199,6 +200,193 @@ public static class RecordingRuntimeLogSink implements RuntimeLogSink { public void writeAuditEvent(UserContext context, RawAuditEvent event) { auditEvents.add(event); } + + @Override + public void writeMutationGovernanceEvent( + UserContext context, MutationGovernanceEvent event) { + governanceEvents.add(event); + } + } + + private static MutationPolicy allowingPolicy( + MutationPolicyIdentity identity, java.util.concurrent.atomic.AtomicInteger calls) { + return new MutationPolicy() { + @Override public MutationPolicyIdentity identity() { return identity; } + @Override public MutationDecision review(UserContext context, MutationPlan plan) { + calls.incrementAndGet(); + return MutationDecision.allow(); + } + }; + } + + private static DummyEntity changedEntity(long id, String comment) { + DummyEntity entity = new DummyEntity(); + entity.updateId(id); + entity.__internalSet("version", 1L); + entity.set$status(EntityStatus.PERSISTED); + entity.updateProperty("name", "private-value"); + entity.setComment(comment); + return entity; + } + + @Test + public void missingMutationPolicyWarnsButDoesNotBlockSave() { + RecordingMutationExecutor executor = new RecordingMutationExecutor(); + RecordingRuntimeLogSink sink = new RecordingRuntimeLogSink(); + TeaQLRuntime runtime = TeaQLRuntime.builder() + .metadata(new DummyMetaFactory()) + .dataService("dummy", executor) + .logSink(sink) + .build(); + + runtime.saveGraph(new DefaultUserContext(runtime), changedEntity(901L, "missing policy")); + + Assert.assertEquals(1, executor.requests.size()); + Assert.assertEquals(1, sink.governanceEvents.size()); + Assert.assertEquals("MUTATION-POLICY-001", sink.governanceEvents.get(0).warningCode()); + Assert.assertEquals(List.of("MUTATION-POLICY-001"), + sink.auditEvents.get(0).governance().warningCodes()); + } + + @Test + public void unapprovedPolicyRunsAndWarnsWhileMatchingApprovalIsQuiet() { + MutationPolicyIdentity identity = + new MutationPolicyIdentity("customer.dummy", "1", "sha256:abc"); + java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(); + MutationPolicy policy = allowingPolicy(identity, calls); + RecordingRuntimeLogSink unapprovedSink = new RecordingRuntimeLogSink(); + TeaQLRuntime unapproved = TeaQLRuntime.builder() + .metadata(new DummyMetaFactory()) + .dataService("dummy", new RecordingMutationExecutor()) + .mutationPolicyRegistry(key -> java.util.Optional.of(policy)) + .logSink(unapprovedSink) + .build(); + unapproved.saveGraph( + new DefaultUserContext(unapproved), changedEntity(902L, "unapproved policy")); + Assert.assertEquals(1, calls.get()); + Assert.assertEquals("MUTATION-POLICY-002", + unapprovedSink.governanceEvents.get(0).warningCode()); + + RecordingRuntimeLogSink approvedSink = new RecordingRuntimeLogSink(); + TeaQLRuntime approved = TeaQLRuntime.builder() + .metadata(new DummyMetaFactory()) + .dataService("dummy", new RecordingMutationExecutor()) + .mutationPolicyRegistry(key -> java.util.Optional.of(policy)) + .mutationPolicyApprovalProvider(key -> java.util.Optional.of( + new MutationPolicyApproval(identity, "security", java.time.Instant.EPOCH))) + .logSink(approvedSink) + .build(); + approved.saveGraph(new DefaultUserContext(approved), changedEntity(903L, "approved policy")); + Assert.assertEquals(2, calls.get()); + Assert.assertTrue(approvedSink.governanceEvents.isEmpty()); + Assert.assertEquals(MutationPolicyApprovalStatus.APPROVED, + approvedSink.auditEvents.get(0).governance().approvalStatus()); + } + + @Test + public void deniedMutationWritesNothingAndRetainsLedger() { + RecordingMutationExecutor executor = new RecordingMutationExecutor(); + MutationPolicy denying = new MutationPolicy() { + @Override public MutationPolicyIdentity identity() { + return new MutationPolicyIdentity("customer.dummy", "1", "sha256:deny"); + } + @Override public MutationDecision review(UserContext context, MutationPlan plan) { + return MutationDecision.deny( + "DUMMY-NAME-DENIED", "name cannot be changed", List.of("name")); + } + }; + TeaQLRuntime runtime = TeaQLRuntime.builder() + .metadata(new DummyMetaFactory()) + .dataService("dummy", executor) + .mutationPolicyRegistry(key -> java.util.Optional.of(denying)) + .build(); + DummyEntity entity = changedEntity(904L, "denied policy"); + + try { + runtime.saveGraph(new DefaultUserContext(runtime), entity); + Assert.fail("denied mutation was persisted"); + } catch (TeaQLRuntimeException expected) { + Assert.assertTrue(expected.getMessage().contains("DUMMY-NAME-DENIED")); + } + + Assert.assertTrue(executor.requests.isEmpty()); + Assert.assertTrue(entity.getEntityMutationLedger() + .changedFieldNames(new EntityKey("Dummy", 904L)).contains("name")); + } + + @Test + public void governanceAuditContainsFieldNamesButNotRawValues() { + RecordingRuntimeLogSink sink = new RecordingRuntimeLogSink(); + TeaQLRuntime runtime = TeaQLRuntime.builder() + .metadata(new DummyMetaFactory()) + .dataService("dummy", new RecordingMutationExecutor()) + .logSink(sink) + .build(); + runtime.saveGraph(new DefaultUserContext(runtime), changedEntity(905L, "safe snapshot")); + + MutationGovernanceSnapshot governance = sink.auditEvents.get(0).governance(); + Assert.assertEquals(List.of("name"), governance.operations().get(0).changedFields()); + Assert.assertFalse(governance.toString().contains("private-value")); + } + + @Test + public void graphPolicyReviewsAllOperationsExactlyOnce() { + RecordingMutationExecutor executor = new RecordingMutationExecutor(); + java.util.concurrent.atomic.AtomicInteger reviews = new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicReference captured = + new java.util.concurrent.atomic.AtomicReference<>(); + MutationPolicy policy = new MutationPolicy() { + @Override public MutationPolicyIdentity identity() { + return new MutationPolicyIdentity("customer.graph", "1", "sha256:graph"); + } + @Override public MutationDecision review(UserContext context, MutationPlan plan) { + reviews.incrementAndGet(); + captured.set(plan); + return MutationDecision.allow(); + } + }; + TeaQLRuntime runtime = TeaQLRuntime.builder() + .metadata(new AdvancedMetaFactory()) + .dataService("dummy", executor) + .mutationPolicyRegistry(key -> java.util.Optional.of(policy)) + .build(); + ContainerEntity root = new ContainerEntity(); + root.updateId(910L); + root.set$status(EntityStatus.PERSISTED); + DummyEntity child = changedEntity(911L, "child change"); + root.updateProperty("rel1", child); + root.setComment("review complete graph"); + + runtime.saveGraph(new DefaultUserContext(runtime), root); + + Assert.assertEquals(1, reviews.get()); + Assert.assertNotNull(captured.get()); + Assert.assertTrue(captured.get().operations().stream() + .anyMatch(operation -> operation.entity().equals(new EntityKey("Dummy", 911L)))); + Assert.assertTrue(captured.get().operations().size() >= 2); + } + + @Test + public void governanceWarningSinkFailureDoesNotBlockPersistence() { + RecordingMutationExecutor executor = new RecordingMutationExecutor(); + RuntimeLogSink throwingSink = new RuntimeLogSink() { + @Override public void writeExecutionLog( + UserContext context, ExecutionMetadata metadata) {} + @Override public void writeMutationGovernanceEvent( + UserContext context, MutationGovernanceEvent event) { + throw new IllegalStateException("sink unavailable"); + } + }; + TeaQLRuntime runtime = TeaQLRuntime.builder() + .metadata(new DummyMetaFactory()) + .dataService("dummy", executor) + .logSink(throwingSink) + .build(); + + runtime.saveGraph( + new DefaultUserContext(runtime), changedEntity(912L, "warning sink failure")); + + Assert.assertEquals(1, executor.requests.size()); } @Test @@ -385,7 +573,7 @@ public void pagedExecutionReturnsRowsAndExactPolicyFilteredTotal() { PageQueryExecutor executor = new PageQueryExecutor(); TeaQLRuntime runtime = TeaQLRuntime.builder() .metadata(new DummyMetaFactory()).dataService("dummy", executor) - .requestPolicy(new RequestPolicy() { + .queryPolicy(new QueryPolicy() { @Override public void enforceSelect(UserContext context, SearchRequest query) { BaseRequest request = (BaseRequest) query; request.appendSearchCriteria(request.createBasicSearchCriteria( @@ -693,17 +881,18 @@ public void testSaveGraphLedgerClassificationAndExecutionOrder() throws Exceptio realEntities.put(new EntityKey("Dummy", 104L), e4); java.lang.reflect.Method method = TeaQLRuntime.class.getDeclaredMethod( - "executeLedgerPlan", UserContext.class, EntityMutationLedger.class, MutationExecutor.class, java.util.Map.class); + "executeLedgerPlan", UserContext.class, EntityMutationLedger.class, + MutationExecutor.class, java.util.Map.class, MutationGovernanceSnapshot.class); method.setAccessible(true); - method.invoke(runtime, new DefaultUserContext(runtime), root, executor, realEntities); + method.invoke(runtime, new DefaultUserContext(runtime), root, executor, realEntities, null); - List requests = executor.requests; + List requests = executor.requests; - List deletes = new ArrayList<>(); - List saves = new ArrayList<>(); + List deletes = new ArrayList<>(); + List saves = new ArrayList<>(); - for (DefaultMutationRequest req : requests) { - if (req.getAction() == DefaultMutationRequest.Action.DELETE) { + for (EntityPersistenceMutation req : requests) { + if (req.getAction() == EntityPersistenceMutation.Action.DELETE) { deletes.add(req); } else { saves.add(req); @@ -711,10 +900,10 @@ public void testSaveGraphLedgerClassificationAndExecutionOrder() throws Exceptio } boolean seenSave = false; - for (DefaultMutationRequest req : requests) { - if (req.getAction() == DefaultMutationRequest.Action.SAVE) { + for (EntityPersistenceMutation req : requests) { + if (req.getAction() == EntityPersistenceMutation.Action.SAVE) { seenSave = true; - } else if (req.getAction() == DefaultMutationRequest.Action.DELETE) { + } else if (req.getAction() == EntityPersistenceMutation.Action.DELETE) { Assert.assertFalse("DELETE should execute before SAVE", seenSave); } } @@ -769,7 +958,7 @@ public void testSaveGraphMergesRelatedEntityMutationLedgers() { Assert.assertSame(rootEntity.getEntityMutationLedger(), listChild1.getEntityMutationLedger()); Assert.assertSame(rootEntity.getEntityMutationLedger(), listChild2.getEntityMutationLedger()); - List requests = executor.requests; + List requests = executor.requests; Assert.assertTrue(requests.stream().anyMatch(r -> r.getEntity().getId().equals(2L))); Assert.assertTrue(requests.stream().anyMatch(r -> r.getEntity().getId().equals(3L))); Assert.assertTrue(requests.stream().anyMatch(r -> r.getEntity().getId().equals(4L))); @@ -813,7 +1002,7 @@ public void testSaveGraphDoesNotConsumeIndependentLedgerFromSameContext() { public void testFailedSaveRetainsOnlyItsOwnPendingLedger() { RecordingMutationExecutor executor = new RecordingMutationExecutor() { @Override - public MutationResult mutate(UserContext context, MutationRequest request) { + public MutationResult mutate(UserContext context, PersistenceMutation request) { throw new TeaQLRuntimeException("expected mutation failure"); } }; @@ -897,7 +1086,7 @@ public void testDelete() { .save(new DefaultUserContext(runtime)); Assert.assertFalse(executor.requests.isEmpty()); - Assert.assertEquals(DefaultMutationRequest.Action.DELETE, executor.requests.get(0).getAction()); + Assert.assertEquals(EntityPersistenceMutation.Action.DELETE, executor.requests.get(0).getAction()); } @Test diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java index 39bf50db..38b5da53 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java @@ -358,11 +358,11 @@ private void addTopNTelemetry( @Override @SuppressWarnings("unchecked") - public MutationResult mutate(UserContext context, MutationRequest request) { - if (!(request instanceof DefaultMutationRequest)) { - throw new TeaQLRuntimeException("Unsupported MutationRequest in PortableSQLDataService"); + public MutationResult mutate(UserContext context, PersistenceMutation request) { + if (!(request instanceof EntityPersistenceMutation)) { + throw new TeaQLRuntimeException("Unsupported PersistenceMutation in PortableSQLDataService"); } - DefaultMutationRequest mutation = (DefaultMutationRequest) request; + EntityPersistenceMutation mutation = (EntityPersistenceMutation) request; Entity entity = mutation.getEntity(); String typeName = entity.typeName(); PortableSQLRepository repository = getRepository(typeName); @@ -370,7 +370,7 @@ public MutationResult mutate(UserContext context, MutationRequest request) { var readbackIntent = context.isQueryExecutionLoggingEnabled() || context.isMutationExecutionLoggingEnabled() ? new io.teaql.core.SqlIntentRedactions() : null; - if (mutation.getAction() == DefaultMutationRequest.Action.SAVE) { + if (mutation.getAction() == EntityPersistenceMutation.Action.SAVE) { if (entity.getId() == null) { Long newId = repository.prepareId(context, entity); ((BaseEntity) entity).__internalSet("id", newId); @@ -388,7 +388,7 @@ public MutationResult mutate(UserContext context, MutationRequest request) { if (entity instanceof BaseEntity) { ((BaseEntity) entity).gotoNextStatus(EntityAction.PERSIST); } - } else if (mutation.getAction() == DefaultMutationRequest.Action.DELETE) { + } else if (mutation.getAction() == EntityPersistenceMutation.Action.DELETE) { repository.deleteInternal(context, Collections.singletonList(entity), readbackIntent); ((BaseEntity) entity).__internalSet("version", -(entity.getVersion() + 1)); if (entity instanceof BaseEntity) { @@ -398,8 +398,8 @@ public MutationResult mutate(UserContext context, MutationRequest request) { Entity persisted = null; if (entity.getId() != null - && (mutation.getAction() == DefaultMutationRequest.Action.SAVE - || mutation.getAction() == DefaultMutationRequest.Action.DELETE)) { + && (mutation.getAction() == EntityPersistenceMutation.Action.SAVE + || mutation.getAction() == EntityPersistenceMutation.Action.DELETE)) { persisted = repository.loadPersistedById(context, entity.getId(), readbackIntent); } return new io.teaql.core.DefaultMutationResult(persisted); diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java index 7a95c699..16937373 100644 --- a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java @@ -6,14 +6,13 @@ import io.teaql.core.BaseRequest; import io.teaql.core.AggrFunction; import io.teaql.core.Entity; -import io.teaql.core.MutationExecutor; +import io.teaql.core.EntityStatus; import io.teaql.core.QueryExecutor; import io.teaql.core.UserContext; import io.teaql.core.SearchCriteria; import io.teaql.core.criteria.Operator; import io.teaql.core.meta.EntityDescriptor; import io.teaql.core.meta.EntityMetaFactory; -import io.teaql.runtime.DefaultMutationRequest; import io.teaql.runtime.DefaultQueryRequest; import io.teaql.runtime.DefaultQueryResult; import io.teaql.runtime.RuntimeTelemetry; @@ -27,18 +26,16 @@ public class TfpEndpointHandler { private final QueryExecutor queryExecutor; - private final MutationExecutor mutationExecutor; private final ObjectMapper objectMapper; private final RuntimeTelemetry telemetry; - public TfpEndpointHandler(QueryExecutor queryExecutor, MutationExecutor mutationExecutor, ObjectMapper objectMapper) { - this(queryExecutor, mutationExecutor, objectMapper, RuntimeTelemetry.NOOP); + public TfpEndpointHandler(QueryExecutor queryExecutor, ObjectMapper objectMapper) { + this(queryExecutor, objectMapper, RuntimeTelemetry.NOOP); } - public TfpEndpointHandler(QueryExecutor queryExecutor, MutationExecutor mutationExecutor, + public TfpEndpointHandler(QueryExecutor queryExecutor, ObjectMapper objectMapper, RuntimeTelemetry telemetry) { this.queryExecutor = queryExecutor; - this.mutationExecutor = mutationExecutor; this.objectMapper = objectMapper; this.telemetry = telemetry == null ? RuntimeTelemetry.NOOP : telemetry; } @@ -321,19 +318,35 @@ private Map handleMutationActive(UserContext context, TrustedFed throw new IllegalArgumentException("Unknown entity: " + entityName); } - Entity entity = (Entity) objectMapper.treeToValue(mappedPayload, descriptor.getTargetType()); + Entity entity = descriptor.getTargetType().getDeclaredConstructor().newInstance(); + java.util.Iterator> mutationFields = + mappedPayload.fields(); + while (mutationFields.hasNext()) { + Map.Entry mutationField = mutationFields.next(); + if ("id".equals(mutationField.getKey()) || "version".equals(mutationField.getKey())) { + continue; + } + io.teaql.core.meta.PropertyDescriptor property = + descriptor.findProperty(mutationField.getKey()); + Class javaType = property == null || property.getType() == null + ? Object.class + : property.getType().javaType(); + Object value = objectMapper.treeToValue(mutationField.getValue(), javaType); + entity.updateProperty(mutationField.getKey(), value); + } if (!"Create".equals(actionStr)) { entity.setProperty("id", root.get("id").longValue()); + ((io.teaql.core.BaseEntity) entity).set$status(EntityStatus.PERSISTED); } if (root.hasNonNull("expectedVersion")) { entity.setProperty("version", root.get("expectedVersion").longValue()); } - DefaultMutationRequest.Action action = "Delete".equalsIgnoreCase(actionStr) ? - DefaultMutationRequest.Action.DELETE : DefaultMutationRequest.Action.SAVE; - - DefaultMutationRequest mutationRequest = new DefaultMutationRequest(entity, action); - mutationExecutor.mutate(context, mutationRequest); + entity.setComment(root.path("comment").asText()); + if ("Delete".equalsIgnoreCase(actionStr)) { + entity.markForDeletion(); + } + context.saveGraph(entity); Map response = new HashMap<>(); response.put("affectedRows", 1); @@ -446,7 +459,7 @@ private void requireNonBlank(JsonNode root, String field, String code) { private void rejectPrivilegedInput(JsonNode node, String path, boolean query) { if (!node.isObject()) throw new TfpEndpointException("TFP_INVALID_REQUEST", "TFP payload must be an object"); java.util.Set forbidden = java.util.Set.of("tenant", "tenantId", "merchant", "merchantId", - "user", "userId", "permissions", "requestPolicy", "purposePolicy", "trustedContext", + "user", "userId", "permissions", "queryPolicy", "purposePolicy", "trustedContext", "hardLimit", "hard_limit", "hardLimitValue", "hard_limit_value", "idSetPagination", "id_set_pagination", "paginationWithIdSet"); java.util.Iterator> fields = node.fields(); diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpConformanceServer.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpConformanceServer.java index b10592d2..9e7dc458 100644 --- a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpConformanceServer.java +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpConformanceServer.java @@ -6,7 +6,7 @@ import io.teaql.core.BaseEntity; import io.teaql.core.DataServiceCapabilities; import io.teaql.core.MutationExecutor; -import io.teaql.core.MutationRequest; +import io.teaql.core.PersistenceMutation; import io.teaql.core.MutationResult; import io.teaql.core.QueryExecutor; import io.teaql.core.QueryRequest; @@ -16,8 +16,10 @@ import io.teaql.core.meta.EntityDescriptor; import io.teaql.core.meta.EntityMetaFactory; import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.DefaultMutationResult; import io.teaql.runtime.DefaultQueryResult; import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.EntityPersistenceMutation; import io.teaql.runtime.TeaQLRuntime; import java.net.InetSocketAddress; import java.util.HashMap; @@ -33,13 +35,14 @@ public static void main(String[] args) throws Exception { int port = Integer.parseInt(System.getenv().getOrDefault("TEAQL_TFP_JAVA_PORT", "19092")); ObjectMapper mapper = new ObjectMapper(); UserContext context = createContext(); - TfpEndpointHandler endpoint = new TfpEndpointHandler(queryExecutor(), mutationExecutor(), mapper); + TfpEndpointHandler endpoint = new TfpEndpointHandler(queryExecutor(), mapper); TrustedFederalContext trusted = trusted(); HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); server.createContext("/query", exchange -> handle(exchange, mapper, () -> endpoint.handleQuery(context, trusted, exchange.getRequestBody().readAllBytes(), headers(exchange)))); server.createContext("/mutate", exchange -> handle(exchange, mapper, - () -> endpoint.handleMutation(context, trusted, exchange.getRequestBody().readAllBytes(), headers(exchange)))); + () -> endpoint.handleMutation(context, trusted, + exchange.getRequestBody().readAllBytes(), headers(exchange)))); server.start(); Runtime.getRuntime().addShutdownHook(new Thread(() -> server.stop(0))); System.out.println("Java TFP conformance server listening on 127.0.0.1:" + port); @@ -84,6 +87,10 @@ private static Map headers(HttpExchange exchange) { private static UserContext createContext() { EntityDescriptor descriptor = new EntityDescriptor(); descriptor.setType("CustomerOrder"); descriptor.setTargetType(CustomerOrder.class); + descriptor.addSimpleProperty("status", String.class); + descriptor.addSimpleProperty("orderNumber", String.class); + descriptor.addSimpleProperty("tenantId", Long.class); + descriptor.addSimpleProperty("reviewed", Boolean.class); SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); metadata.register(descriptor); EntityDescriptor statusDescriptor = new EntityDescriptor(); @@ -93,7 +100,11 @@ private static UserContext createContext() { statusDescriptor.addSimpleProperty("label", String.class); metadata.register(statusDescriptor); EntityMetaFactory.registerGlobal(null); - return new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata).build()); + return new DefaultUserContext(TeaQLRuntime.builder() + .metadata(metadata) + .dataService("default", mutationExecutor()) + .idGenerationService((context, entity) -> 9001L) + .build()); } private static TrustedFederalContext trusted() { @@ -136,8 +147,9 @@ public QueryResult query(io.teaql.core.UserContext context, QueryRequest request private static MutationExecutor mutationExecutor() { return new MutationExecutor() { - public MutationResult mutate(io.teaql.core.UserContext context, MutationRequest request) { - return null; + public MutationResult mutate(io.teaql.core.UserContext context, PersistenceMutation request) { + return new DefaultMutationResult( + ((EntityPersistenceMutation) request).getEntity()); } public String name() { return "tfp-conformance"; } public DataServiceCapabilities capabilities() { return new DataServiceCapabilities(); } @@ -160,6 +172,24 @@ public static final class CustomerOrder extends BaseEntity { public void setTenantId(Long value) { tenantId = value; } public Boolean getReviewed() { return reviewed; } public void setReviewed(Boolean value) { reviewed = value; } + @Override public void __internalSet(String property, Object value) { + switch (property) { + case "status" -> status = (String) value; + case "orderNumber" -> orderNumber = (String) value; + case "tenantId" -> tenantId = (Long) value; + case "reviewed" -> reviewed = (Boolean) value; + default -> super.__internalSet(property, value); + } + } + @Override public Object __internalGet(String property) { + return switch (property) { + case "status" -> status; + case "orderNumber" -> orderNumber; + case "tenantId" -> tenantId; + case "reviewed" -> reviewed; + default -> super.__internalGet(property); + }; + } } public static final class OrderStatus extends BaseEntity { diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java index 5d90d8b9..b602993a 100644 --- a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java @@ -9,18 +9,26 @@ import io.teaql.core.DataServiceCapabilities; import io.teaql.core.FunctionApply; import io.teaql.core.MutationExecutor; -import io.teaql.core.MutationRequest; +import io.teaql.core.MutationDecision; +import io.teaql.core.MutationPlan; +import io.teaql.core.MutationPolicy; +import io.teaql.core.MutationPolicyIdentity; +import io.teaql.core.MutationPolicyRegistry; +import io.teaql.core.PersistenceMutation; import io.teaql.core.MutationResult; import io.teaql.core.QueryExecutor; import io.teaql.core.QueryRequest; import io.teaql.core.QueryResult; import io.teaql.core.SmartList; +import io.teaql.core.TeaQLRuntimeException; import io.teaql.core.UserContext; import io.teaql.core.meta.EntityDescriptor; import io.teaql.core.meta.EntityMetaFactory; import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.DefaultMutationResult; import io.teaql.runtime.DefaultQueryResult; import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.EntityPersistenceMutation; import io.teaql.runtime.TeaQLRuntime; import io.teaql.core.criteria.Operator; import java.util.Map; @@ -30,12 +38,14 @@ public class TfpEndpointPolicyTest { private QueryRequest capturedQuery; - private MutationRequest capturedMutation; + private PersistenceMutation capturedMutation; private UserContext context; + private DefaultUserContext mutationContext; + private SimpleEntityMetaFactory metadata; @Before public void metadata() { - SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); + metadata = new SimpleEntityMetaFactory(); EntityDescriptor descriptor = new EntityDescriptor(); descriptor.setType("Probe"); descriptor.setTargetType(Probe.class); descriptor.addSimpleProperty("id", Long.class); @@ -45,7 +55,8 @@ public void metadata() { status.setType("ProbeStatus"); status.setTargetType(ProbeStatus.class); metadata.register(status); EntityMetaFactory.registerGlobal(null); - context = context(metadata); + context = context(metadata, MutationPolicyRegistry.empty()); + mutationContext = (DefaultUserContext) context; } @Test @@ -87,18 +98,43 @@ public void rejectsForbiddenFilterUnknownFieldsAndUnsafeMutation() { @Test public void updateLoadsIdentityAndExpectedVersionWithoutJacksonSetters() throws Exception { TfpEndpointHandler handler = handler(); - handler.handleMutation(context, trusted(), ("{\"entity\":\"Probe\",\"action\":\"Update\"," + handler.handleMutation(mutationContext, trusted(), ("{\"entity\":\"Probe\",\"action\":\"Update\"," + "\"id\":42,\"expectedVersion\":3,\"payload\":{\"status\":\"PAID\"}," + "\"comment\":\"cross-language update\"}").getBytes()); - io.teaql.runtime.DefaultMutationRequest request = - (io.teaql.runtime.DefaultMutationRequest) capturedMutation; + io.teaql.runtime.EntityPersistenceMutation request = + (io.teaql.runtime.EntityPersistenceMutation) capturedMutation; Probe entity = (Probe) request.getEntity(); org.junit.Assert.assertEquals(Long.valueOf(42), entity.getId()); org.junit.Assert.assertEquals(Long.valueOf(3), entity.getVersion()); org.junit.Assert.assertEquals("PAID", entity.getStatus()); } + @Test + public void mutationCannotBypassRuntimeMutationPolicy() { + capturedMutation = null; + MutationPolicy denying = new MutationPolicy() { + @Override public MutationPolicyIdentity identity() { + return new MutationPolicyIdentity("tfp.probe", "1", "sha256:deny"); + } + @Override public MutationDecision review(UserContext context, MutationPlan plan) { + return MutationDecision.deny( + "TFP-POLICY-DENIED", "federated mutation denied", java.util.List.of()); + } + }; + TfpEndpointHandler handler = handler(key -> java.util.Optional.of(denying)); + + TeaQLRuntimeException error = assertThrows(TeaQLRuntimeException.class, + () -> handler.handleMutation(mutationContext, trusted(), + ("{\"entity\":\"Probe\",\"action\":\"Update\"," + + "\"id\":42,\"expectedVersion\":3," + + "\"payload\":{\"status\":\"PAID\"}," + + "\"comment\":\"must be governed\"}").getBytes())); + + org.junit.Assert.assertTrue(error.getMessage().contains("TFP-POLICY-DENIED")); + org.junit.Assert.assertNull(capturedMutation); + } + @Test public void parsesExtendedPortablePredicatesAndNullableBoolean() throws Exception { TfpEndpointHandler handler = handler(); @@ -202,12 +238,12 @@ public void isolatesTopLevelFacetAndMutationMetadataByInvokingContext() throws E alternateRequest.getFacetRequests().get(0).getRequest().returnType()); handler.handleMutation(alternateContext, trusted(), - "{\"entity\":\"Probe\",\"action\":\"Create\"," + "{\"entity\":\"Probe\",\"action\":\"Create\"," .concat("\"payload\":{\"status\":\"NEW\"},\"comment\":\"create probe\"}") .getBytes()); org.junit.Assert.assertEquals( AlternateProbe.class, - ((io.teaql.runtime.DefaultMutationRequest) capturedMutation) + ((io.teaql.runtime.EntityPersistenceMutation) capturedMutation) .getEntity().getClass()); handler.handleQuery(context, trusted(), query("id")); @@ -217,6 +253,10 @@ public void isolatesTopLevelFacetAndMutationMetadataByInvokingContext() throws E } private TfpEndpointHandler handler() { + return handler(MutationPolicyRegistry.empty()); + } + + private TfpEndpointHandler handler(MutationPolicyRegistry mutationPolicies) { QueryExecutor query = new QueryExecutor() { public QueryResult query(io.teaql.core.UserContext c, QueryRequest request) { capturedQuery = request; return new DefaultQueryResult(new SmartList<>()); @@ -224,18 +264,34 @@ public QueryResult query(io.teaql.core.UserContext c, QueryRequest request) { public String name() { return "test"; } public DataServiceCapabilities capabilities() { return new DataServiceCapabilities(); } }; - MutationExecutor mutation = new MutationExecutor() { - public MutationResult mutate(io.teaql.core.UserContext c, MutationRequest request) { - capturedMutation = request; return null; + mutationContext = (DefaultUserContext) context(metadata, mutationPolicies); + context = mutationContext; + return new TfpEndpointHandler(query, new ObjectMapper()); + } + + private MutationExecutor mutationExecutor() { + return new MutationExecutor() { + public MutationResult mutate(io.teaql.core.UserContext c, PersistenceMutation request) { + capturedMutation = request; + return new DefaultMutationResult(((EntityPersistenceMutation) request).getEntity()); } public String name() { return "test"; } public DataServiceCapabilities capabilities() { return new DataServiceCapabilities(); } }; - return new TfpEndpointHandler(query, mutation, new ObjectMapper()); } private UserContext context(SimpleEntityMetaFactory metadata) { - return new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata).build()); + return context(metadata, MutationPolicyRegistry.empty()); + } + + private UserContext context( + SimpleEntityMetaFactory metadata, MutationPolicyRegistry mutationPolicies) { + return new DefaultUserContext(TeaQLRuntime.builder() + .metadata(metadata) + .dataService("default", mutationExecutor()) + .mutationPolicyRegistry(mutationPolicies) + .idGenerationService((userContext, entity) -> 1000L) + .build()); } private byte[] query(String field) { @@ -268,6 +324,13 @@ public static final class Probe extends BaseEntity { public String typeName() { return "Probe"; } public String getStatus() { return status; } public void setStatus(String value) { status = value; } + @Override public void __internalSet(String property, Object value) { + if ("status".equals(property)) status = (String) value; + else super.__internalSet(property, value); + } + @Override public Object __internalGet(String property) { + return "status".equals(property) ? status : super.__internalGet(property); + } } public static final class ProbeStatus extends BaseEntity { public String typeName() { return "ProbeStatus"; } diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointTelemetryTest.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointTelemetryTest.java index 68bcfa49..11e54442 100644 --- a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointTelemetryTest.java +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointTelemetryTest.java @@ -8,7 +8,7 @@ import io.teaql.core.BaseEntity; import io.teaql.core.DataServiceCapabilities; import io.teaql.core.MutationExecutor; -import io.teaql.core.MutationRequest; +import io.teaql.core.PersistenceMutation; import io.teaql.core.MutationResult; import io.teaql.core.QueryExecutor; import io.teaql.core.QueryRequest; @@ -18,8 +18,10 @@ import io.teaql.core.meta.EntityDescriptor; import io.teaql.core.meta.EntityMetaFactory; import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.DefaultMutationResult; import io.teaql.runtime.DefaultQueryResult; import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.EntityPersistenceMutation; import io.teaql.runtime.RuntimeTelemetry; import io.teaql.runtime.TeaQLRuntime; import java.util.ArrayList; @@ -41,7 +43,11 @@ public void registerMetadata() { descriptor.setTargetType(Probe.class); metadata.register(descriptor); EntityMetaFactory.registerGlobal(null); - context = new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata).build()); + context = new DefaultUserContext(TeaQLRuntime.builder() + .metadata(metadata) + .dataService("default", mutationExecutor()) + .idGenerationService((userContext, entity) -> 77L) + .build()); } @Test @@ -50,7 +56,7 @@ public void recordsServerQuerySuccessAndCardinality() throws Exception { rows.add(new Probe()); TfpEndpointHandler handler = new TfpEndpointHandler( queryExecutor(request -> new DefaultQueryResult(rows)), - mutationExecutor(), new ObjectMapper(), telemetry); + new ObjectMapper(), telemetry); Map response = handler.handleQuery(context, trusted(), queryPayload().getBytes()); @@ -67,7 +73,7 @@ public void recordsFailureAndRethrowsOriginalError() { IllegalStateException original = new IllegalStateException("provider failed"); TfpEndpointHandler handler = new TfpEndpointHandler( queryExecutor(request -> { throw original; }), - mutationExecutor(), new ObjectMapper(), telemetry); + new ObjectMapper(), telemetry); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> handler.handleQuery(context, trusted(), queryPayload().getBytes())); @@ -80,7 +86,7 @@ public void recordsFailureAndRethrowsOriginalError() { public void recordsServerMutationLifecycle() throws Exception { TfpEndpointHandler handler = new TfpEndpointHandler( queryExecutor(request -> new DefaultQueryResult(new SmartList<>())), - mutationExecutor(), new ObjectMapper(), telemetry); + new ObjectMapper(), telemetry); handler.handleMutation(context, trusted(), "{\"entity\":\"Probe\",\"action\":\"Create\",\"payload\":{},\"comment\":\"create probe\"}".getBytes()); @@ -96,7 +102,7 @@ public void activatesCarrierBeforeStartingServerSpanAndRestoresAfterward() throw telemetry.expectedCarrier = Map.of("TraceParent", "00-trace-span-01"); TfpEndpointHandler handler = new TfpEndpointHandler( queryExecutor(request -> new DefaultQueryResult(new SmartList<>())), - mutationExecutor(), new ObjectMapper(), telemetry); + new ObjectMapper(), telemetry); handler.handleQuery(context, trusted(), queryPayload().getBytes(), telemetry.expectedCarrier); @@ -140,8 +146,9 @@ public QueryResult query(io.teaql.core.UserContext context, QueryRequest request private static MutationExecutor mutationExecutor() { return new MutationExecutor() { @Override - public MutationResult mutate(io.teaql.core.UserContext context, MutationRequest request) { - return null; + public MutationResult mutate(io.teaql.core.UserContext context, PersistenceMutation request) { + return new DefaultMutationResult( + ((EntityPersistenceMutation) request).getEntity()); } @Override From 7cfa2eb1af40fab1b124e1bdfe4be1ce0946135e Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 29 Sep 2026 21:22:10 +0800 Subject: [PATCH 2/4] examples: demonstrate customer mutation policy --- examples/order-management/README.md | 26 +++++++- .../com/teaql/example/MutationAuthority.java | 14 +++++ .../com/teaql/example/OrderManagementApp.java | 62 ++++++++++++++++++- .../teaql/example/OrderMutationPolicy.java | 48 ++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 examples/order-management/java-app-console/src/main/java/com/teaql/example/MutationAuthority.java create mode 100644 examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderMutationPolicy.java diff --git a/examples/order-management/README.md b/examples/order-management/README.md index 83a5aa73..eb43a022 100644 --- a/examples/order-management/README.md +++ b/examples/order-management/README.md @@ -8,7 +8,12 @@ mvn -q install -DskipTests mvn -q exec:java -pl java-app-console ``` -The first run creates `.local/order.db`, ensures schema from generated metadata, seeds through generated entities, performs a governed query, and saves an audited preset. The second run demonstrates idempotency. +The first run creates `.local/order.db`, ensures schema from generated metadata, +seeds through generated entities, performs a governed query, and saves an +audited preset. It also installs a customer-owned `OrderMutationPolicy`, denies +an unauthorized high-value order as one complete graph, and queries SQLite to +prove that zero order rows were written. The second run demonstrates +idempotency and repeats the policy proof. Read `java-app-console/.../OrderManagementApp.java` first (handwritten), then `java-lib-core/lib/.../Q.java`, `CustomerOrderRequest.java`, and `CustomerOrder.java` (generated). Java is the naming and governance gold standard: `comment(...)` may appear anywhere before `purpose(...)`; only the purposed request exposes execute methods. @@ -19,6 +24,25 @@ Expect one `WEB-2026-001` row dated `2026-08-12` with amount `129.95`. The first ## Customize it Change the `withOrderNumberContaining` filter, ordering, or projection in the app and rerun. Add business behavior only under `java-app-console`; regenerate everything under `java-lib-core`. This library was generated from the shared Order Management model used by the six-language example suite, but that model and the generator are not runtime prerequisites. + +### Customize whole-graph mutation policy + +`OrderMutationPolicy.java` is application-owned code. It reviews the immutable +`MutationPlan` after Checker/Fix and graph assembly but before the first +provider write. The example requires a trusted `MutationAuthority` capability +for orders above `10000.00` and returns the stable denial code +`ORDER-HIGH-VALUE-AUTHORITY-REQUIRED` when that authority is absent. + +`OrderManagementApp.context(...)` shows the three customer configuration +points: + +1. register policy by the stable `CustomerOrder.saveGraph` request key; +2. provide approval for the exact policy identity and fingerprint; +3. install trusted request authority in `UserContext`, outside remote input. + +Change the installed permission set to +`Set.of(OrderMutationPolicy.HIGH_VALUE_PERMISSION)` to exercise the allowed +path. Do not place trusted authority in request JSON or generated entities. ### Materialized-list hard limit `executeForList` protects the service by applying a default hard limit of 10,000 rows. A requested page size above that ceiling fails explicitly. Trusted application code can call `hardLimit(...)` to override the outer-query ceiling. **Caution:** most applications should not override it; do so only for a reviewed, exceptional requirement. This setting does not describe streaming execution. diff --git a/examples/order-management/java-app-console/src/main/java/com/teaql/example/MutationAuthority.java b/examples/order-management/java-app-console/src/main/java/com/teaql/example/MutationAuthority.java new file mode 100644 index 00000000..cbba3058 --- /dev/null +++ b/examples/order-management/java-app-console/src/main/java/com/teaql/example/MutationAuthority.java @@ -0,0 +1,14 @@ +package com.teaql.example; + +import java.util.Set; + +/** Trusted application authority installed in UserContext by server-side code. */ +public record MutationAuthority(Set permissions) { + public MutationAuthority { + permissions = Set.copyOf(permissions == null ? Set.of() : permissions); + } + + public boolean permits(String permission) { + return permissions.contains(permission); + } +} diff --git a/examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderManagementApp.java b/examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderManagementApp.java index 0b4758b8..3e5ec079 100644 --- a/examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderManagementApp.java +++ b/examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderManagementApp.java @@ -8,7 +8,9 @@ import com.teaql.ordermanagementservice.ordersearchpreset.OrderSearchPreset; import io.teaql.core.DataServiceExecutor; import io.teaql.core.InternalIdGenerationService; +import io.teaql.core.MutationPolicyApproval; import io.teaql.core.SmartList; +import io.teaql.core.TeaQLRuntimeException; import io.teaql.core.UserContext; import io.teaql.core.meta.EntityMetaFactory; import io.teaql.core.meta.SimpleEntityMetaFactory; @@ -24,7 +26,10 @@ import java.sql.DriverManager; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.time.Instant; import java.time.LocalDate; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Logger; import javax.sql.DataSource; @@ -97,6 +102,8 @@ public static void main(String[] args) throws Exception { preset.auditAs("Save idempotent quick-start search preset").save(context); System.out.println("[mutation] saved preset #" + preset.getId()); } else System.out.println("[mutation] preset #" + presets.get(0).getId() + " already exists"); + + demonstrateMutationPolicy(context, platform); } private static UserContext context(DataServiceExecutor executor) { @@ -105,10 +112,61 @@ private static UserContext context(DataServiceExecutor executor) { EntityMetaFactory.registerGlobal(metadata); AtomicLong ids = new AtomicLong(1000); InternalIdGenerationService idGeneration = (context, entity) -> ids.getAndIncrement(); + OrderMutationPolicy orderPolicy = new OrderMutationPolicy(); TeaQLRuntime runtime = TeaQLRuntime.builder().metadata(metadata) .dataService("default", executor).dataService("sqlite", executor) - .idGenerationService(idGeneration).build(); - return new DefaultUserContext(runtime); + .idGenerationService(idGeneration) + .mutationPolicyRegistry(requestKey -> "CustomerOrder.saveGraph".equals(requestKey) + ? Optional.of(orderPolicy) + : Optional.empty()) + .mutationPolicyApprovalProvider(identity -> identity.equals(orderPolicy.identity()) + ? Optional.of(new MutationPolicyApproval( + identity, + "example-security-review", + Instant.parse("2026-09-29T12:00:00Z"))) + : Optional.empty()) + .build(); + DefaultUserContext context = new DefaultUserContext(runtime); + context.putAttribute( + MutationAuthority.class.getName(), new MutationAuthority(Set.of())); + return context; + } + + private static void demonstrateMutationPolicy( + UserContext context, CommercePlatform platform) { + String rejectedOrderNumber = "WEB-2026-HIGH-VALUE-DENIED"; + Customer rejectedCustomer = new Customer() + .updateName("Rejected High Value Customer") + .updateEmail("not-persisted@example.invalid") + .updateCommercePlatform(platform); + CustomerOrder rejectedOrder = new CustomerOrder() + .updateOrderNumber(rejectedOrderNumber) + .updateOrderDate(LocalDate.of(2026, 9, 29)) + .updateTotalAmount(new BigDecimal("25000.00")) + .updateStatusToPending() + .updateCustomer(rejectedCustomer) + .updateCommercePlatform(platform); + + try { + rejectedOrder.auditAs("Demonstrate customer high-value order policy") + .save(context); + throw new IllegalStateException("The high-value mutation should have been denied"); + } catch (TeaQLRuntimeException expected) { + if (!expected.getMessage().contains("ORDER-HIGH-VALUE-AUTHORITY-REQUIRED")) { + throw expected; + } + } + + SmartList rejectedRows = Q.customerOrders() + .withOrderNumberIs(rejectedOrderNumber) + .comment("Verify the denied high-value graph wrote no order") + .purpose("Prove customer MutationPolicy denial is atomic") + .executeForList(context); + if (!rejectedRows.isEmpty()) { + throw new IllegalStateException("Denied high-value order was persisted"); + } + System.out.println( + "[mutation-policy] denied high-value graph before persistence; verified 0 order rows"); } private record DriverManagerDataSource(String url) implements DataSource { diff --git a/examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderMutationPolicy.java b/examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderMutationPolicy.java new file mode 100644 index 00000000..d2d3ff1e --- /dev/null +++ b/examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderMutationPolicy.java @@ -0,0 +1,48 @@ +package com.teaql.example; + +import io.teaql.core.MutationDecision; +import io.teaql.core.MutationOperationKind; +import io.teaql.core.MutationPlan; +import io.teaql.core.MutationPolicy; +import io.teaql.core.MutationPolicyIdentity; +import io.teaql.core.UserContext; +import java.math.BigDecimal; +import java.util.List; + +/** Customer-owned whole-graph policy for the order-management example. */ +public final class OrderMutationPolicy implements MutationPolicy { + public static final String HIGH_VALUE_PERMISSION = "order.submitHighValue"; + public static final BigDecimal HIGH_VALUE_THRESHOLD = new BigDecimal("10000.00"); + public static final MutationPolicyIdentity IDENTITY = new MutationPolicyIdentity( + "example.order-mutation", "1.0.0", "example:order-policy-2026-09-29"); + + @Override + public MutationPolicyIdentity identity() { + return IDENTITY; + } + + @Override + public MutationDecision review(UserContext context, MutationPlan plan) { + boolean containsHighValueOrder = plan.operations().stream() + .filter(operation -> operation.kind() == MutationOperationKind.CREATE + || operation.kind() == MutationOperationKind.UPDATE) + .map(operation -> operation.changedValues().get("totalAmount")) + .filter(BigDecimal.class::isInstance) + .map(BigDecimal.class::cast) + .anyMatch(amount -> amount.compareTo(HIGH_VALUE_THRESHOLD) > 0); + + if (!containsHighValueOrder) { + return MutationDecision.allow(); + } + + MutationAuthority authority = context.capability(MutationAuthority.class); + if (authority != null && authority.permits(HIGH_VALUE_PERMISSION)) { + return MutationDecision.allow(); + } + + return MutationDecision.deny( + "ORDER-HIGH-VALUE-AUTHORITY-REQUIRED", + "Orders above 10000.00 require the high-value order authority", + List.of("totalAmount")); + } +} From 5c5bc61d0fe69402db5615ccfb0202cf62d3bc33 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Wed, 30 Sep 2026 02:22:26 +0800 Subject: [PATCH 3/4] fix(tfp): create mutation entities through metadata factory --- .../src/main/java/io/teaql/tfp/TfpEndpointHandler.java | 2 +- .../src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java index 16937373..25683d6d 100644 --- a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java @@ -318,7 +318,7 @@ private Map handleMutationActive(UserContext context, TrustedFed throw new IllegalArgumentException("Unknown entity: " + entityName); } - Entity entity = descriptor.getTargetType().getDeclaredConstructor().newInstance(); + Entity entity = descriptor.createEntity(); java.util.Iterator> mutationFields = mappedPayload.fields(); while (mutationFields.hasNext()) { diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java index b602993a..ea6caf34 100644 --- a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java @@ -49,6 +49,11 @@ public void metadata() { EntityDescriptor descriptor = new EntityDescriptor(); descriptor.setType("Probe"); descriptor.setTargetType(Probe.class); descriptor.addSimpleProperty("id", Long.class); + descriptor.withEntitySupplier(() -> { + Probe probe = new Probe(); + probe.factoryCreated = true; + return probe; + }); descriptor.addSimpleProperty("status", String.class); metadata.register(descriptor); EntityDescriptor status = new EntityDescriptor(); @@ -105,6 +110,7 @@ public void updateLoadsIdentityAndExpectedVersionWithoutJacksonSetters() throws io.teaql.runtime.EntityPersistenceMutation request = (io.teaql.runtime.EntityPersistenceMutation) capturedMutation; Probe entity = (Probe) request.getEntity(); + org.junit.Assert.assertTrue(entity.factoryCreated); org.junit.Assert.assertEquals(Long.valueOf(42), entity.getId()); org.junit.Assert.assertEquals(Long.valueOf(3), entity.getVersion()); org.junit.Assert.assertEquals("PAID", entity.getStatus()); @@ -321,6 +327,7 @@ private void assertCode(String code, Throwing action) { private interface Throwing { void run() throws Exception; } public static final class Probe extends BaseEntity { private String status; + private boolean factoryCreated; public String typeName() { return "Probe"; } public String getStatus() { return status; } public void setStatus(String value) { status = value; } From 00bbddaf32e172a44fb4359041990b9eaa498c44 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Wed, 30 Sep 2026 13:03:31 +0800 Subject: [PATCH 4/4] fix: integrate mutation policy with current runtime boundaries Refs #196 --- .../main/java/io/teaql/runtime/LogPrivacy.java | 3 ++- .../main/java/io/teaql/runtime/RawAuditEvent.java | 15 +++++++++++++++ .../main/java/io/teaql/runtime/TeaQLRuntime.java | 10 +++++----- .../teaql/runtime/mutation/MutationPlanner.java | 14 +++++++------- .../java/io/teaql/runtime/TeaQLRuntimeTest.java | 4 ++-- .../java/io/teaql/tfp/TfpConformanceServer.java | 1 + .../java/io/teaql/tfp/TfpEndpointPolicyTest.java | 8 ++++++++ .../io/teaql/tfp/TfpEndpointTelemetryTest.java | 1 + 8 files changed, 41 insertions(+), 15 deletions(-) diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java b/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java index 66a2c95c..6b91966d 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java @@ -202,6 +202,7 @@ public static RawAuditEvent audit(RawAuditEvent source, boolean allow) { if (source.entityId() != null) intentValues.add(source.entityId()); return new RawAuditEvent(source.kind(), source.entityType(), source.entityId(), changes, trace(source.traceChain(), intentValues), scrub(source.actor(), intentValues), source.category(), - scrub(source.reason(), intentValues), source.resultingVersion(), source.occurredAt()); + scrub(source.reason(), intentValues), source.resultingVersion(), source.occurredAt(), + source.governance()); } } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/RawAuditEvent.java b/teaql-runtime/src/main/java/io/teaql/runtime/RawAuditEvent.java index ff205b30..2099d122 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/RawAuditEvent.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/RawAuditEvent.java @@ -22,4 +22,19 @@ public record RawAuditEvent( traceChain = List.copyOf(traceChain == null ? List.of() : traceChain); occurredAt = occurredAt == null ? Instant.now() : occurredAt; } + + public RawAuditEvent( + MutationAuditKind kind, + String entityType, + Object entityId, + List changes, + List traceChain, + String actor, + String category, + String reason, + Long resultingVersion, + Instant occurredAt) { + this(kind, entityType, entityId, changes, traceChain, actor, category, reason, + resultingVersion, occurredAt, null); + } } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index 2d3a5f9a..81293562 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -246,15 +246,15 @@ public Stream executeForStream( throw new TeaQLRuntimeException( "[PURPOSE REQUIRED] Missing .purpose() on streaming query execution."); } - if (requestPolicy != null) { - requestPolicy.enforceSelect(context, request); + if (queryPolicy != null) { + queryPolicy.enforceSelect(context, request); } return executeForStreamResolved(context, request); } /** * Executes a framework-owned nested stream under an already-authorized root query. The - * nested request still passes through RequestPolicy before reaching the provider. + * nested request still passes through QueryPolicy before reaching the provider. */ public Stream internalExecuteForStream( UserContext context, SearchRequest request) { @@ -262,8 +262,8 @@ public Stream internalExecuteForStream( throw new TeaQLRuntimeException( "[INTERNAL QUERY CONTEXT REQUIRED] Nested streaming query has no authorized root trace."); } - if (requestPolicy != null) { - requestPolicy.enforceSelect(context, request); + if (queryPolicy != null) { + queryPolicy.enforceSelect(context, request); } return executeForStreamResolved(context, request); } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/mutation/MutationPlanner.java b/teaql-runtime/src/main/java/io/teaql/runtime/mutation/MutationPlanner.java index 6d782538..fb13b062 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/mutation/MutationPlanner.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/mutation/MutationPlanner.java @@ -4,7 +4,7 @@ import io.teaql.core.meta.EntityDescriptor; import io.teaql.core.meta.EntityMetaFactory; import io.teaql.core.meta.PropertyDescriptor; -import io.teaql.runtime.DefaultMutationRequest; +import io.teaql.runtime.EntityPersistenceMutation; import java.util.*; @@ -152,8 +152,8 @@ void executeLedgerPlan(UserContext ctx, EntityMutationLedger root, MutationExecu deleteEntity.markForDeletion(); if (root.getComment() != null) deleteEntity.setComment(root.getComment()); - DefaultMutationRequest mutationRequest = new DefaultMutationRequest( - deleteEntity, DefaultMutationRequest.Action.DELETE); + EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( + deleteEntity, EntityPersistenceMutation.Action.DELETE); mutationExecutor.mutate(ctx, mutationRequest); } @@ -175,17 +175,17 @@ void executeLedgerPlan(UserContext ctx, EntityMutationLedger root, MutationExecu // 3. Execute Inserts executeBatchMutations(ctx, insertBatches, changeSet, realEntities, root, mutationExecutor, - DefaultMutationRequest.Action.SAVE, false); + EntityPersistenceMutation.Action.SAVE, false); // 4. Execute Updates executeBatchMutations(ctx, updateBatches, changeSet, realEntities, root, mutationExecutor, - DefaultMutationRequest.Action.SAVE, true); + EntityPersistenceMutation.Action.SAVE, true); } private void executeBatchMutations(UserContext ctx, Map> batches, EntityChangeSet changeSet, Map realEntities, EntityMutationLedger root, MutationExecutor mutationExecutor, - DefaultMutationRequest.Action action, boolean markAsUpdated) { + EntityPersistenceMutation.Action action, boolean markAsUpdated) { for (Map.Entry> entry : batches.entrySet()) { String entityName = entry.getKey(); List keys = entry.getValue(); @@ -213,7 +213,7 @@ private void executeBatchMutations(UserContext ctx, Map> } if (root.getComment() != null) entity.setComment(root.getComment()); - DefaultMutationRequest mutationRequest = new DefaultMutationRequest(entity, action); + EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation(entity, action); mutationExecutor.mutate(ctx, mutationRequest); entity.clearUpdatedProperties(); } diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java index f5f5a377..b3fa2a09 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java @@ -603,7 +603,7 @@ public void streamingExecutionAppliesRequestPolicyBeforeProvider() { TeaQLRuntime runtime = TeaQLRuntime.builder() .metadata(new DummyMetaFactory()) .dataService("dummy", executor) - .requestPolicy(new RequestPolicy() { + .queryPolicy(new QueryPolicy() { @Override public void enforceSelect( UserContext context, SearchRequest query) { policyCalls.incrementAndGet(); @@ -636,7 +636,7 @@ public void internalStreamingRequiresAuthorizedRootAndAppliesRequestPolicy() { TeaQLRuntime runtime = TeaQLRuntime.builder() .metadata(new DummyMetaFactory()) .dataService("dummy", executor) - .requestPolicy(new RequestPolicy() { + .queryPolicy(new QueryPolicy() { @Override public void enforceSelect( UserContext context, SearchRequest query) { policyCalls.incrementAndGet(); diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpConformanceServer.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpConformanceServer.java index 9e7dc458..ac326285 100644 --- a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpConformanceServer.java +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpConformanceServer.java @@ -87,6 +87,7 @@ private static Map headers(HttpExchange exchange) { private static UserContext createContext() { EntityDescriptor descriptor = new EntityDescriptor(); descriptor.setType("CustomerOrder"); descriptor.setTargetType(CustomerOrder.class); + descriptor.withEntitySupplier(CustomerOrder::new); descriptor.addSimpleProperty("status", String.class); descriptor.addSimpleProperty("orderNumber", String.class); descriptor.addSimpleProperty("tenantId", Long.class); diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java index ea6caf34..1b3e663d 100644 --- a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java @@ -216,6 +216,7 @@ public void isolatesTopLevelFacetAndMutationMetadataByInvokingContext() throws E EntityDescriptor probe = new EntityDescriptor(); probe.setType("Probe"); probe.setTargetType(AlternateProbe.class); + probe.withEntitySupplier(AlternateProbe::new); probe.addSimpleProperty("id", Long.class); probe.addSimpleProperty("status", String.class); alternate.register(probe); @@ -347,6 +348,13 @@ public static final class AlternateProbe extends BaseEntity { public String typeName() { return "Probe"; } public String getStatus() { return status; } public void setStatus(String value) { status = value; } + @Override public void __internalSet(String property, Object value) { + if ("status".equals(property)) status = (String) value; + else super.__internalSet(property, value); + } + @Override public Object __internalGet(String property) { + return "status".equals(property) ? status : super.__internalGet(property); + } } public static final class AlternateProbeStatus extends BaseEntity { public String typeName() { return "ProbeStatus"; } diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointTelemetryTest.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointTelemetryTest.java index 11e54442..bedd08e9 100644 --- a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointTelemetryTest.java +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointTelemetryTest.java @@ -41,6 +41,7 @@ public void registerMetadata() { EntityDescriptor descriptor = new EntityDescriptor(); descriptor.setType("Probe"); descriptor.setTargetType(Probe.class); + descriptor.withEntitySupplier(Probe::new); metadata.register(descriptor); EntityMetaFactory.registerGlobal(null); context = new DefaultUserContext(TeaQLRuntime.builder()