From 3a8a3784f5cdea223201e3854944c34625ac2789 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 24 Sep 2026 15:26:45 +0400 Subject: [PATCH 1/2] fix(AF-1092): drop RLS-bound predicate text from cost estimates The estimate dry-run binds the submitter's row-security values and engines inline them into plan predicate text, which was persisted and shown to every QUERY_VIEW_ALL holder. When row security applies, store the plan tree without node detail and without the raw plan. V188 strips the rows stored before the fix. Closes #1092 --- .../DefaultQueryCostEstimateService.java | 28 ++-- ..._redact_query_estimate_plan_predicates.sql | 30 +++++ ...PlanRedactionMigrationIntegrationTest.java | 124 ++++++++++++++++++ .../DefaultQueryCostEstimateServiceTest.java | 68 ++++++++++ docs/03-data-model.md | 4 +- docs/04-api-spec.md | 2 +- docs/05-backend.md | 1 + docs/07-security.md | 8 ++ 8 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V188__redact_query_estimate_plan_predicates.sql create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/persistence/QueryEstimatePlanRedactionMigrationIntegrationTest.java diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryCostEstimateService.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryCostEstimateService.java index dca1f52a2..aebd2a74a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryCostEstimateService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryCostEstimateService.java @@ -77,8 +77,10 @@ public Optional estimateSubmittedQuery(UUID queryRequestI var request = buildRequest(snapshot); var dryRun = queryExecutor.dryRun(request); Long affectedRows = countAffectedRows(request, snapshot.queryType()); - return persistAndPublish(queryRequestId, - toCommand(snapshot, dryRun, affectedRows, durationMs(start)), true); + boolean redactPredicates = !request.rowSecurityPredicates().isEmpty() + || !dryRun.appliedRowSecurityPolicyIds().isEmpty(); + return persistAndPublish(queryRequestId, toCommand(snapshot, dryRun, affectedRows, + redactPredicates, durationMs(start)), true); } catch (RuntimeException ex) { log.warn("Cost estimate failed for query {}: {}", queryRequestId, ex.getMessage()); var command = new PersistQueryEstimateCommand(null, snapshot.queryType(), false, null, @@ -119,7 +121,7 @@ private Long countAffectedRows(QueryExecutionRequest request, QueryType queryTyp private PersistQueryEstimateCommand toCommand(QueryRequestSnapshot snapshot, QueryDryRunResult dryRun, Long affectedRows, - int durationMs) { + boolean redactPredicates, int durationMs) { if (!dryRun.supported()) { var reason = dryRun.unsupportedReason() != null ? dryRun.unsupportedReason() @@ -142,8 +144,8 @@ private PersistQueryEstimateCommand toCommand(QueryRequestSnapshot snapshot, affectedRows, access != null ? truncateTo(access.operation(), 128) : null, root != null ? root.estimatedCost() : null, - planJson(root), - dryRun.rawPlan(), null, false, null, durationMs); + planJson(root, redactPredicates), + redactPredicates ? null : dryRun.rawPlan(), null, false, null, durationMs); } /** @@ -176,15 +178,19 @@ private static PersistQueryEstimateCommand withAffectedRows(PersistQueryEstimate command.unsupportedReason(), command.failed(), command.errorMessage(), durationMs); } - /** Serializes the plan tree with explicit snake_case keys — the frontend's PlanTree shape. */ - private String planJson(QueryPlanNode root) { + /** + * Serializes the plan tree with explicit snake_case keys — the frontend's PlanTree shape. With + * {@code redactPredicates} every node's {@code detail} is dropped: the dry-run bound the + * submitter's row-security values, and engines inline them into predicate text (#1092). + */ + private String planJson(QueryPlanNode root, boolean redactPredicates) { if (root == null) { return null; } - return objectMapper.writeValueAsString(planNode(root)); + return objectMapper.writeValueAsString(planNode(root, redactPredicates)); } - private ObjectNode planNode(QueryPlanNode node) { + private ObjectNode planNode(QueryPlanNode node, boolean redactPredicates) { var out = objectMapper.createObjectNode(); out.put("operation", node.operation()); out.put("target", node.target()); @@ -198,10 +204,10 @@ private ObjectNode planNode(QueryPlanNode node) { } else { out.putNull("estimated_cost"); } - out.put("detail", node.detail()); + out.put("detail", redactPredicates ? null : node.detail()); var children = out.putArray("children"); for (var child : node.children()) { - children.add(planNode(child)); + children.add(planNode(child, redactPredicates)); } return out; } diff --git a/backend/src/main/resources/db/migration/V188__redact_query_estimate_plan_predicates.sql b/backend/src/main/resources/db/migration/V188__redact_query_estimate_plan_predicates.sql new file mode 100644 index 000000000..71a6e3ea7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V188__redact_query_estimate_plan_predicates.sql @@ -0,0 +1,30 @@ +-- #1092: the cost-estimate dry-run binds the submitter's row-security values, and engines inline +-- them into plan predicate text (PostgreSQL Index Cond / Filter, MySQL attached_condition, +-- MongoDB stage filters). New estimates drop that text whenever row security applied; which +-- existing rows had it was never recorded, so strip every stored plan's per-node `detail` and +-- raw plan. Operation, target, row and cost figures are kept. + +CREATE FUNCTION pg_temp.af_strip_plan_detail(node JSONB) RETURNS JSONB + LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF node IS NULL OR jsonb_typeof(node) <> 'object' THEN + RETURN node; + END IF; + RETURN node || jsonb_build_object( + 'detail', NULL::JSONB, + 'children', COALESCE( + (SELECT jsonb_agg(pg_temp.af_strip_plan_detail(child) ORDER BY ord) + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(node -> 'children') = 'array' + THEN node -> 'children' ELSE '[]'::JSONB END) + WITH ORDINALITY AS c(child, ord)), + '[]'::JSONB)); +END; +$$; + +UPDATE query_estimates + SET plan = pg_temp.af_strip_plan_detail(plan), + raw_plan = NULL + WHERE plan IS NOT NULL OR raw_plan IS NOT NULL; + +DROP FUNCTION pg_temp.af_strip_plan_detail(JSONB); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/persistence/QueryEstimatePlanRedactionMigrationIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/persistence/QueryEstimatePlanRedactionMigrationIntegrationTest.java new file mode 100644 index 000000000..811cfe27f --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/persistence/QueryEstimatePlanRedactionMigrationIntegrationTest.java @@ -0,0 +1,124 @@ +package com.bablsoft.accessflow.core.internal.persistence; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.json.JsonMapper; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Upgrade test for V188 (#1092): drives Flyway to V187 on a private container, seeds estimates + * whose plans carry inlined row-security values, then applies V188. V187 belongs to #937; + * targeting it before that migration lands simply stops at V186. + */ +class QueryEstimatePlanRedactionMigrationIntegrationTest { + + private static final Map PLACEHOLDERS = Map.of( + "app_role", "accessflow", + "audit_role", "accessflow_audit", + "rag_pgvector_dimensions", "1536"); + + @SuppressWarnings("resource") + static PostgreSQLContainer postgres = new PostgreSQLContainer("pgvector/pgvector:pg18") + .withInitScript("db/test-init-audit-roles.sql"); + + static JdbcTemplate jdbc; + + @BeforeAll + static void migrateToTheVersionBeforeV188() { + postgres.start(); + jdbc = new JdbcTemplate(new DriverManagerDataSource( + postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())); + flyway("187").migrate(); + } + + @AfterAll + static void stopContainer() { + postgres.stop(); + } + + private static Flyway flyway(String target) { + return Flyway.configure() + .dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()) + .locations("classpath:db/migration") + .placeholders(PLACEHOLDERS) + .target(target) + .load(); + } + + @Test + void v188StripsEveryNodeDetailAndRawPlanButKeepsPlanFigures() { + var nested = estimate(""" + {"operation": "Nested Loop", "target": null, "estimated_rows": 10.0, + "estimated_cost": 8.5, "detail": "(o.user_id = u.id)", + "children": [ + {"operation": "Index Scan", "target": "users", "estimated_rows": 1.0, + "estimated_cost": 2.0, + "detail": "((email)::text = 'dana@acme.example'::text)", "children": []}, + {"operation": "Seq Scan", "target": "orders", "estimated_rows": 9.0, + "estimated_cost": 4.0, "detail": null, "children": []}]} + """, "[{\"Plan\": {\"Filter\": \"'dana@acme.example'\"}}]"); + var rawOnly = estimate(null, "EXPLAIN text 'dana@acme.example'"); + var unsupported = estimate(null, null); + + flyway("188").migrate(); + + String plan = jdbc.queryForObject( + "SELECT plan::text FROM query_estimates WHERE id = ?", String.class, nested); + assertThat(plan).doesNotContain("dana@acme.example").doesNotContain("o.user_id"); + var root = JsonMapper.builder().build().readTree(plan); + assertThat(root.get("detail").isNull()).isTrue(); + assertThat(root.get("operation").asString()).isEqualTo("Nested Loop"); + assertThat(root.get("estimated_cost").asDouble()).isEqualTo(8.5); + assertThat(root.get("children")).hasSize(2); + assertThat(root.get("children").get(0).get("operation").asString()) + .isEqualTo("Index Scan"); + assertThat(root.get("children").get(0).get("target").asString()).isEqualTo("users"); + assertThat(root.get("children").get(0).get("detail").isNull()).isTrue(); + assertThat(root.get("children").get(1).get("operation").asString()) + .isEqualTo("Seq Scan"); + assertThat(rawPlan(nested)).isNull(); + assertThat(rawPlan(rawOnly)).isNull(); + assertThat(jdbc.queryForObject("SELECT count(*) FROM query_estimates WHERE id = ?", + Long.class, unsupported)).isEqualTo(1L); + } + + private static String rawPlan(UUID id) { + return jdbc.queryForObject("SELECT raw_plan FROM query_estimates WHERE id = ?", + String.class, id); + } + + /** Seeds an estimate without its FK chain — the migration only rewrites query_estimates. */ + private static UUID estimate(String planJson, String rawPlan) { + var id = UUID.randomUUID(); + jdbc.execute((ConnectionCallback) connection -> { + try (var statement = connection.createStatement()) { + statement.execute("SET session_replication_role = replica"); + } + try (var insert = connection.prepareStatement(""" + INSERT INTO query_estimates (id, query_request_id, supported, plan, raw_plan) + VALUES (?, ?, true, CAST(? AS JSONB), ?) + """)) { + insert.setObject(1, id); + insert.setObject(2, UUID.randomUUID()); + insert.setString(3, planJson); + insert.setString(4, rawPlan); + insert.executeUpdate(); + } + try (var statement = connection.createStatement()) { + statement.execute("SET session_replication_role = DEFAULT"); + } + return null; + }); + return id; + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryCostEstimateServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryCostEstimateServiceTest.java index 09c4274f1..17e4934b4 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryCostEstimateServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryCostEstimateServiceTest.java @@ -12,6 +12,8 @@ import com.bablsoft.accessflow.core.api.QueryRequestSnapshot; import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.QueryType; +import com.bablsoft.accessflow.core.api.ResolvedRowSecurityPredicate; +import com.bablsoft.accessflow.core.api.RowSecurityOperator; import com.bablsoft.accessflow.core.api.RowSecurityResolutionService; import com.bablsoft.accessflow.core.events.QueryEstimateCompletedEvent; import com.bablsoft.accessflow.core.events.QueryEstimateFailedEvent; @@ -189,6 +191,72 @@ void writePlanDescendsIntoAccessNodeForScanTypeAndEstimate() { assertThat(captor.getValue().estimatedRows()).isEqualTo(2_400_000L); } + @Test + void keepsPredicateDetailAndRawPlanWithoutRowSecurity() { + stubSelectDryRun(List.of(), Set.of()); + + service.estimateSubmittedQuery(queryRequestId); + + var command = capturePersisted(); + assertThat(command.planJson()).contains("(active = false)").contains("(id = 7)"); + assertThat(command.rawPlan()).isEqualTo("[raw (active = false)]"); + } + + @Test + void dropsPredicateDetailAndRawPlanWhenRowSecurityResolved() { + var policyId = UUID.randomUUID(); + stubSelectDryRun(List.of(new ResolvedRowSecurityPredicate(policyId, "public.users", + "email", RowSecurityOperator.EQUALS, List.of("dana@acme.example"))), + Set.of(policyId)); + + service.estimateSubmittedQuery(queryRequestId); + + var command = capturePersisted(); + assertThat(command.planJson()).doesNotContain("active = false") + .doesNotContain("id = 7") + .contains("\"operation\":\"Nested Loop\"") + .contains("\"operation\":\"Index Scan\"") + .contains("\"detail\":null"); + assertThat(command.rawPlan()).isNull(); + assertThat(command.scanType()).isEqualTo("Nested Loop"); + assertThat(command.estimatedRows()).isEqualTo(10L); + } + + @Test + void dropsPredicateDetailWhenEngineReportsAppliedPolicyWithoutResolvedDirective() { + stubSelectDryRun(List.of(), Set.of(UUID.randomUUID())); + + service.estimateSubmittedQuery(queryRequestId); + + var command = capturePersisted(); + assertThat(command.planJson()).doesNotContain("active = false"); + assertThat(command.rawPlan()).isNull(); + } + + private void stubSelectDryRun(List predicates, + Set appliedPolicyIds) { + when(lookupService.findByQueryRequestId(queryRequestId)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(persistedSnapshot())); + when(queryRequestLookupService.findById(queryRequestId)) + .thenReturn(Optional.of(snapshot(QueryType.SELECT, false))); + when(rowSecurityResolutionService.resolveApplicable(organizationId, datasourceId, userId)) + .thenReturn(predicates); + var index = new QueryPlanNode("Index Scan", "orders", 1.0, 2.0, "(id = 7)"); + var root = new QueryPlanNode("Nested Loop", null, 10.0, 8.0, "(active = false)", + List.of(index)); + when(queryExecutor.dryRun(any())).thenReturn(QueryDryRunResult.of("postgresql", + QueryType.SELECT, 10L, root, "[raw (active = false)]", appliedPolicyIds, + Duration.ZERO)); + when(persistenceService.persist(eq(queryRequestId), any())).thenReturn(estimateId); + } + + private PersistQueryEstimateCommand capturePersisted() { + var captor = ArgumentCaptor.forClass(PersistQueryEstimateCommand.class); + verify(persistenceService).persist(eq(queryRequestId), captor.capture()); + return captor.getValue(); + } + @Test void selectSkipsAffectedRowCount() { when(lookupService.findByQueryRequestId(queryRequestId)) diff --git a/docs/03-data-model.md b/docs/03-data-model.md index a07166c37..7cb684f79 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -1157,8 +1157,8 @@ Persisted pre-flight cost / blast-radius estimate for a submitted query (AF-624, | `affected_row_count` | BIGINT nullable — exact governed count for UPDATE/DELETE; null when the shape can't be provably counted (joins, `USING`, MERGE, …) or the engine doesn't support counting | | `scan_type` | VARCHAR(128) nullable — the plan's root operation (e.g. `Seq Scan`, `COLLSCAN`) | | `estimated_cost` | DOUBLE PRECISION nullable — the plan's root cost when the engine exposes one | -| `plan` | JSONB nullable — the snake_case plan-node tree (same shape as the dry-run endpoint's `plan`) | -| `raw_plan` | TEXT nullable — the engine's raw plan output | +| `plan` | JSONB nullable — the snake_case plan-node tree (same shape as the dry-run endpoint's `plan`). Every node's `detail` is null when row security applied to the submitter, because engines inline the bound values into predicate text (#1092; V188 stripped all rows stored earlier) | +| `raw_plan` | TEXT nullable — the engine's raw plan output; null when row security applied (#1092) | | `unsupported_reason` | VARCHAR(500) nullable — localized reason when `supported=false` | | `failed` | BOOLEAN NOT NULL DEFAULT false — true when the computation hit an unexpected error (sentinel row) | | `error_message` | VARCHAR(500) nullable — failure reason when `failed=true` | diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 89a8c5a32..694dda74c 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -1845,7 +1845,7 @@ Each subsequent row contains the same fields as `QueryListItemView`. `ai_risk_le `matched_policy` is the routing policy that decided this query's routing (AF-379); `null` when no policy matched and the query fell through to the datasource's review plan. `policy_name` is `null` when the matched policy was later deleted. The frontend renders a "matched policy" alert on the detail page when this object is present. See [docs/05-backend.md → "Policy-as-code routing engine"](05-backend.md#policy-as-code-routing-engine-af-379). -`cost_estimate` is the query's persisted pre-flight cost / blast-radius estimate (AF-624), computed automatically and asynchronously right after submission — `null` while it is still being computed (typically only during `PENDING_AI`). `supported=false` with a localized `unsupported_reason` marks engines/statement shapes with no plan concept; `failed=true` with `error_message` marks a computation error. `affected_row_count` is the exact governed row count for UPDATE/DELETE (relational `SELECT COUNT(*)` rewrite, or the engine's native non-mutating count) and is `null` when the shape cannot be provably counted; `estimated_rows`/`scan_type`/`estimated_cost` come from the plan root, and `plan` reuses the dry-run endpoint's plan-node shape — see [POST /queries/dry-run](#post-queriesdry-run--response-200) and [docs/05-backend.md → "Automatic pre-flight cost estimate"](05-backend.md#automatic-pre-flight-cost-estimate-af-624). The `query.estimate_complete` WebSocket event signals completion. +`cost_estimate` is the query's persisted pre-flight cost / blast-radius estimate (AF-624), computed automatically and asynchronously right after submission — `null` while it is still being computed (typically only during `PENDING_AI`). `supported=false` with a localized `unsupported_reason` marks engines/statement shapes with no plan concept; `failed=true` with `error_message` marks a computation error. `affected_row_count` is the exact governed row count for UPDATE/DELETE (relational `SELECT COUNT(*)` rewrite, or the engine's native non-mutating count) and is `null` when the shape cannot be provably counted; `estimated_rows`/`scan_type`/`estimated_cost` come from the plan root, and `plan` reuses the dry-run endpoint's plan-node shape (when row security applied to the submitter, every node's `detail` and `raw_plan` are `null` so bound attribute values are never exposed — #1092) — see [POST /queries/dry-run](#post-queriesdry-run--response-200) and [docs/05-backend.md → "Automatic pre-flight cost estimate"](05-backend.md#automatic-pre-flight-cost-estimate-af-624). The `query.estimate_complete` WebSocket event signals completion. `approval_prediction` is the query's advisory approval-outcome prediction (AF-645) — the probability that a human reviewer approves it, computed from the organization's own decision history. It is a **triage signal only**: it never auto-approves, auto-rejects, or feeds the routing engine, grant coverage, or any other decision path, and clients must present it in neutral wording rather than as an instruction. The block is `null` until the asynchronous scoring pass persists a row (a query only ever gets one — auto-approved, auto-rejected, grant-covered and break-glass queries never reach review, so they never get scored at all). Once present, exactly one of three shapes applies: `probability` in `[0,1]` with `skipped=false, failed=false`; `skipped=true` with a `skipped_reason` machine token the client localizes — the closed set is `DISABLED` (the feature is switched off) and `MODEL_NOT_SERVING` (the org has too little history, the model failed its holdout quality gate, or it was trained against an older feature schema); or `failed=true`, the sentinel for an unexpected scoring error. `probability` is `null` on both sentinel shapes. The block is **omitted entirely** — not merely blanked — for callers who lack `QUERY_REVIEW` and for the query's own submitter, a reviewer reading their own request included: the prediction is a triage aid for whoever decides, and letting a submitter read the likely verdict on their own query would invite cancelling and resubmitting until it looks better. See [docs/05-backend.md → "Approval-outcome prediction"](05-backend.md#approval-outcome-prediction-af-645). The `query.prediction_complete` WebSocket event signals completion. diff --git a/docs/05-backend.md b/docs/05-backend.md index 244edfffa..d2767fe9c 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -1057,6 +1057,7 @@ The result is a `SelectExecutionResult` mapped to `SampleRowsResponse` for `GET - **Computation.** The submitter's row-security directives are resolved (`RowSecurityResolutionService`) so the plan and count reflect the *governed* statement, then `QueryExecutor.dryRun` runs the existing per-dialect EXPLAIN / engine `dryRun` path. For UPDATE/DELETE, `QueryExecutor.countAffectedRows` additionally computes the exact count: **relational** datasources rewrite the parsed single-table statement into `SELECT COUNT(*) FROM [WHERE …]` (`proxy.internal.dryrun.AffectedRowCounter` — join / `UPDATE … FROM` / `DELETE … USING` shapes degrade to null) and run it through the normal SELECT path (so `RowSecurityRewriter` applies); **engine-managed** datasources delegate to the `QueryEngine.countAffectedRows` SPI default method (overridden by MongoDB `countDocuments`, Couchbase SQL++ `SELECT COUNT(*)` splice, Neo4j `MATCH … RETURN count(*)`, Elasticsearch/OpenSearch `_count` — each applying its native row security and failing closed on uncountable shapes; Redis, Cassandra/ScyllaDB, DynamoDB, and the warehouse engines inherit the unsupported default). - **Bounded.** Both calls run under the dedicated `accessflow.proxy.estimate-timeout` (`ACCESSFLOW_PROXY_ESTIMATE_TIMEOUT`, default `PT5S`) instead of the full execution statement timeout — an estimate is a best-effort signal, never worth a long lock. - **Every path persists a row.** Success stores the full estimate; engines with no plan concept store `supported=false` + a localized `unsupported_reason` (a transactional `BEGIN…COMMIT` envelope and a SQL Server dry-run under row security short-circuit the same way); an unexpected error stores a `failed=true` sentinel with the message — mirroring the AI module's sentinel convention, so the frontend can always render a definitive state. Completion publishes `QueryEstimateCompletedEvent` (or `QueryEstimateFailedEvent`), which the realtime module fans out as the `query.estimate_complete` WebSocket event. +- **Row-security values are redacted from the plan.** The dry-run binds the submitter's resolved row-security values, and engines inline bound values into predicate text (PostgreSQL `Index Cond` / `Filter`, MySQL `attached_condition`, MongoDB stage filters). So whenever a row-security directive was resolved for the submitter, or the engine reports an applied policy, the service persists the plan tree with every node's `detail` set to null and `raw_plan` null (#1092). Operation, target, row and cost figures, and therefore routing and the AI prompt summary, are unaffected. Keeping predicate text safely is tracked in #1093 (generic-plan EXPLAIN) and #1094 (bound-value redaction). - **Consumers.** `GET /queries/{id}` embeds the row as `cost_estimate`; `QueryReviewStateMachine.buildContext` reads it live (fail-closed) for the `estimated_rows` / `scan_type` routing conditions; `DefaultAiAnalyzerService` renders it into the `{{cost_estimate}}` prompt placeholder. ### Data classification & derivation (AF-447) diff --git a/docs/07-security.md b/docs/07-security.md index bdfc64132..364e3317a 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -994,6 +994,14 @@ on a table — a primary access boundary at the row grain, enforced in the proxy - **Audit.** The ids of the policies actually applied to an execution ride on the `QUERY_EXECUTED` metadata (`applied_row_security_policy_ids`); no row data is stored. Policy create/update/delete emit `ROW_SECURITY_POLICY_CREATED/UPDATED/DELETED` audit actions. +- **Bound values never reach a persisted plan.** The pre-flight cost estimate (AF-624) dry-runs the + governed statement with the submitter's values bound, and engines inline those values into plan + predicate text (PostgreSQL `Index Cond` / `Filter`, MySQL `attached_condition`, MongoDB stage + filters). Whenever row security applied, the persisted estimate drops every plan node's `detail` + and the `raw_plan`, so reviewers and `QUERY_VIEW_ALL` holders never see another user's attribute + values and nothing is kept at rest (#1092; V188 stripped the rows stored before the fix). + Operation, table, row and cost figures are kept. The ad-hoc `POST /queries/dry-run` still returns + the full plan, but only to the caller about their own values, and stores nothing. ### Per-table row-limit policies (#934) From fe5c9332b452867a48b81b338c268e2b8f6db083 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 24 Sep 2026 15:46:43 +0400 Subject: [PATCH 2/2] test(AF-1092): drop stale V187 note from migration test --- .../QueryEstimatePlanRedactionMigrationIntegrationTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/persistence/QueryEstimatePlanRedactionMigrationIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/persistence/QueryEstimatePlanRedactionMigrationIntegrationTest.java index 811cfe27f..3e8101981 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/persistence/QueryEstimatePlanRedactionMigrationIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/persistence/QueryEstimatePlanRedactionMigrationIntegrationTest.java @@ -17,8 +17,7 @@ /** * Upgrade test for V188 (#1092): drives Flyway to V187 on a private container, seeds estimates - * whose plans carry inlined row-security values, then applies V188. V187 belongs to #937; - * targeting it before that migration lands simply stops at V186. + * whose plans carry inlined row-security values, then applies V188. */ class QueryEstimatePlanRedactionMigrationIntegrationTest {