From 4614328fe393e510b79f671cf91bbbb416e1233a Mon Sep 17 00:00:00 2001
From: Tigran Babloyan
Date: Thu, 24 Sep 2026 14:58:22 +0400
Subject: [PATCH 1/2] feat(AF-937): persist the effective executed SQL on the
query snapshot
---
.../api/RegulatoryAuditTrailRow.java | 7 +-
.../internal/ComplianceCsvWriter.java | 4 +-
.../internal/CompliancePdfWriter.java | 8 +-
.../DefaultComplianceReportService.java | 3 +-
.../web/ComplianceReportResponse.java | 5 +-
.../core/api/SelectExecutionResult.java | 31 +++++--
.../core/api/UpdateExecutionResult.java | 11 ++-
.../proxy/internal/DefaultQueryExecutor.java | 43 +++++++---
.../workflow/api/QuerySnapshotService.java | 6 +-
.../workflow/api/QuerySnapshotView.java | 7 +-
.../workflow/events/QueryExecutedEvent.java | 13 ++-
.../DefaultQueryLifecycleService.java | 5 +-
.../internal/DefaultQuerySnapshotService.java | 3 +-
.../internal/QuerySnapshotListener.java | 2 +-
.../internal/QuerySnapshotMapper.java | 3 +-
.../entity/QuerySnapshotEntity.java | 4 +
.../internal/web/QueryDetailResponse.java | 17 ++++
.../internal/web/QueryReadController.java | 7 +-
...__add_effective_sql_to_query_snapshots.sql | 5 ++
.../api/ComplianceReportRowsTest.java | 2 +-
.../compliance/api/ComplianceReportTest.java | 2 +-
.../internal/ClassificationJoinerTest.java | 2 +-
.../internal/ComplianceCsvWriterTest.java | 7 +-
.../internal/CompliancePdfWriterTest.java | 6 +-
.../DefaultComplianceReportServiceTest.java | 4 +-
...aultResultExportGovernanceServiceTest.java | 2 +-
.../DefaultResultExportServiceTest.java | 2 +-
.../web/ComplianceReportResponseTest.java | 5 +-
.../core/api/SelectExecutionResultTest.java | 21 +++++
.../core/api/UpdateExecutionResultTest.java | 34 ++++++++
...tQueryExecutorPostgresIntegrationTest.java | 29 +++++++
.../internal/DefaultQueryExecutorTest.java | 81 ++++++++++++++++++-
.../workflow/api/QuerySnapshotViewTest.java | 2 +-
.../DefaultQueryLifecycleServiceTest.java | 12 ++-
.../DefaultQueryReplayServiceTest.java | 2 +-
.../DefaultQuerySnapshotServiceTest.java | 18 +++--
.../QuerySnapshotListenerIntegrationTest.java | 22 +++++
.../internal/QuerySnapshotListenerTest.java | 18 ++++-
.../internal/QuerySnapshotMapperTest.java | 2 +
.../internal/web/QueryDetailResponseTest.java | 13 +++
.../QueryReadControllerIntegrationTest.java | 2 +
docs/03-data-model.md | 1 +
docs/04-api-spec.md | 7 +-
docs/05-backend.md | 6 +-
docs/06-frontend.md | 4 +-
docs/07-security.md | 10 ++-
e2e/tests/row-security-policies.spec.ts | 62 ++++++++++++--
frontend/src/locales/de.json | 12 ++-
frontend/src/locales/en.json | 12 ++-
frontend/src/locales/es.json | 12 ++-
frontend/src/locales/fr.json | 12 ++-
frontend/src/locales/hy.json | 12 ++-
frontend/src/locales/ru.json | 12 ++-
frontend/src/locales/zh-CN.json | 12 ++-
.../pages/admin/AuditorDashboardPage.test.tsx | 39 +++++++++
.../src/pages/admin/AuditorDashboardPage.tsx | 5 ++
.../src/pages/queries/QueryDetailPage.tsx | 8 +-
.../src/pages/queries/QuerySqlView.test.tsx | 52 ++++++++++++
frontend/src/pages/queries/QuerySqlView.tsx | 60 ++++++++++++++
frontend/src/types/api.ts | 7 ++
help-corpus/corpus.jsonl | 4 +-
help-corpus/manifest.json | 14 ++--
.../configuration/audit-compliance/index.html | 7 +-
.../docs/configuration/datasources/index.html | 4 +-
website/sitemap.xml | 2 +-
65 files changed, 752 insertions(+), 96 deletions(-)
create mode 100644 backend/src/main/resources/db/migration/V187__add_effective_sql_to_query_snapshots.sql
create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/api/UpdateExecutionResultTest.java
create mode 100644 frontend/src/pages/queries/QuerySqlView.test.tsx
create mode 100644 frontend/src/pages/queries/QuerySqlView.tsx
diff --git a/backend/src/main/java/com/bablsoft/accessflow/compliance/api/RegulatoryAuditTrailRow.java b/backend/src/main/java/com/bablsoft/accessflow/compliance/api/RegulatoryAuditTrailRow.java
index d98ba0989..72736b6aa 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/compliance/api/RegulatoryAuditTrailRow.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/compliance/api/RegulatoryAuditTrailRow.java
@@ -8,7 +8,9 @@
/**
* One executed DDL/DELETE operation with its approvers, for the
- * {@link ComplianceReportType#REGULATORY_AUDIT_TRAIL} report (#459).
+ * {@link ComplianceReportType#REGULATORY_AUDIT_TRAIL} report (#459). {@code effectiveSql} is the
+ * statement as actually executed, bound values redacted as {@code ?}, or {@code null} when no
+ * row-security / soft-delete rewrite occurred (#937).
*/
public record RegulatoryAuditTrailRow(
UUID queryRequestId,
@@ -19,7 +21,8 @@ public record RegulatoryAuditTrailRow(
QueryType queryType,
String sqlText,
List approvers,
- Instant executedAt) {
+ Instant executedAt,
+ String effectiveSql) {
public RegulatoryAuditTrailRow {
approvers = approvers == null ? List.of() : List.copyOf(approvers);
diff --git a/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/ComplianceCsvWriter.java b/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/ComplianceCsvWriter.java
index 643623ddc..9708972ca 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/ComplianceCsvWriter.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/ComplianceCsvWriter.java
@@ -53,7 +53,8 @@ private void writeClassifiedAccess(StringBuilder sb, List rows) {
writeRow(sb, List.of("query_request_id", "datasource_id", "datasource_name",
- "submitter_email", "query_type", "sql_text", "approvers", "executed_at"));
+ "submitter_email", "query_type", "sql_text", "effective_sql", "approvers",
+ "executed_at"));
for (var row : rows) {
writeRow(sb, List.of(
str(row.queryRequestId()),
@@ -62,6 +63,7 @@ private void writeAuditTrail(StringBuilder sb, List row
nullToEmpty(row.submitterEmail()),
str(row.queryType()),
nullToEmpty(row.sqlText()),
+ nullToEmpty(row.effectiveSql()),
formatApprovers(row.approvers()),
str(row.executedAt())));
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/CompliancePdfWriter.java b/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/CompliancePdfWriter.java
index 937509066..b038fdffb 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/CompliancePdfWriter.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/CompliancePdfWriter.java
@@ -95,14 +95,16 @@ private void drawClassifiedAccess(Canvas c, List rows
}
private void drawAuditTrail(Canvas c, List rows) throws IOException {
- var headers = List.of("Executed At", "Datasource", "Submitter", "Type", "SQL", "Approvers");
- float[] weights = {2f, 2f, 2f, 1f, 4f, 3f};
+ var headers = List.of("Executed At", "Datasource", "Submitter", "Type", "SQL",
+ "Effective SQL", "Approvers");
+ float[] weights = {2f, 2f, 2f, 1f, 3f, 3f, 2f};
var data = new ArrayList>();
for (var row : rows) {
data.add(List.of(
str(row.executedAt()), nullToEmpty(row.datasourceName()),
nullToEmpty(row.submitterEmail()), str(row.queryType()),
- nullToEmpty(row.sqlText()), formatApprovers(row.approvers())));
+ nullToEmpty(row.sqlText()), nullToEmpty(row.effectiveSql()),
+ formatApprovers(row.approvers())));
}
drawTable(c, headers, weights, data);
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/DefaultComplianceReportService.java b/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/DefaultComplianceReportService.java
index 2a1bd62b4..9ffa6ea72 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/DefaultComplianceReportService.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/DefaultComplianceReportService.java
@@ -124,7 +124,8 @@ private ComplianceReport regulatoryTrail(UUID organizationId, ComplianceReportRe
snapshot.queryType(),
snapshot.sqlText(),
reviewDecisionsParser.approvers(snapshot.reviewDecisionsJson()),
- snapshot.executedAt()));
+ snapshot.executedAt(),
+ snapshot.effectiveSql()));
}
return new ComplianceReport(request.type(), organizationId, request.from(), request.to(),
diff --git a/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/web/ComplianceReportResponse.java b/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/web/ComplianceReportResponse.java
index 5b8a02938..eb9b3da3f 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/web/ComplianceReportResponse.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/compliance/internal/web/ComplianceReportResponse.java
@@ -56,7 +56,8 @@ public record RegulatoryAuditTrailRow(
QueryType queryType,
String sqlText,
List approvers,
- Instant executedAt) {
+ Instant executedAt,
+ String effectiveSql) {
}
public record RetentionAdherenceRow(
@@ -93,7 +94,7 @@ public static ComplianceReportResponse from(ComplianceReport report) {
.map(a -> new Approver(a.email(), a.displayName(), a.decision(),
a.decidedAt()))
.toList(),
- r.executedAt()))
+ r.executedAt(), r.effectiveSql()))
.toList();
var retention = report.retentionAdherence().stream()
.map(r -> new RetentionAdherenceRow(
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/SelectExecutionResult.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/SelectExecutionResult.java
index ec18aa592..78f377c1a 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/api/SelectExecutionResult.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/SelectExecutionResult.java
@@ -13,7 +13,8 @@ public record SelectExecutionResult(
Duration duration,
Set appliedMaskingPolicyIds,
Set appliedRowSecurityPolicyIds,
- String truncatedReason) implements QueryExecutionResult {
+ String truncatedReason,
+ String effectiveSql) implements QueryExecutionResult {
/** {@link #truncatedReason()} value when the configured row cap cut the result short. */
public static final String TRUNCATED_ROW_LIMIT = "ROW_LIMIT";
@@ -31,13 +32,14 @@ public record SelectExecutionResult(
public SelectExecutionResult(List columns, List> rows, long rowCount,
boolean truncated, Duration duration) {
- this(columns, rows, rowCount, truncated, duration, Set.of(), Set.of(), null);
+ this(columns, rows, rowCount, truncated, duration, Set.of(), Set.of(), null, null);
}
public SelectExecutionResult(List columns, List> rows, long rowCount,
boolean truncated, Duration duration,
Set appliedMaskingPolicyIds) {
- this(columns, rows, rowCount, truncated, duration, appliedMaskingPolicyIds, Set.of(), null);
+ this(columns, rows, rowCount, truncated, duration, appliedMaskingPolicyIds, Set.of(), null,
+ null);
}
public SelectExecutionResult(List columns, List> rows, long rowCount,
@@ -45,12 +47,31 @@ public SelectExecutionResult(List columns, List> rows
Set appliedMaskingPolicyIds,
Set appliedRowSecurityPolicyIds) {
this(columns, rows, rowCount, truncated, duration, appliedMaskingPolicyIds,
- appliedRowSecurityPolicyIds, null);
+ appliedRowSecurityPolicyIds, null, null);
+ }
+
+ /** Pre-#937 canonical shape — kept so published engine plugins stay binary-compatible. */
+ public SelectExecutionResult(List columns, List> rows, long rowCount,
+ boolean truncated, Duration duration,
+ Set appliedMaskingPolicyIds,
+ Set appliedRowSecurityPolicyIds, String truncatedReason) {
+ this(columns, rows, rowCount, truncated, duration, appliedMaskingPolicyIds,
+ appliedRowSecurityPolicyIds, truncatedReason, null);
}
/** Returns a copy of this result with the given row-security policy ids attached. */
public SelectExecutionResult withRowSecurityPolicyIds(Set ids) {
return new SelectExecutionResult(columns, rows, rowCount, truncated, duration,
- appliedMaskingPolicyIds, ids, truncatedReason);
+ appliedMaskingPolicyIds, ids, truncatedReason, effectiveSql);
+ }
+
+ /**
+ * Returns a copy carrying the statement as actually executed (#937) — the row-security /
+ * soft-delete rewrite with bound values left as {@code ?}; {@code null} when nothing was
+ * rewritten.
+ */
+ public SelectExecutionResult withEffectiveSql(String sql) {
+ return new SelectExecutionResult(columns, rows, rowCount, truncated, duration,
+ appliedMaskingPolicyIds, appliedRowSecurityPolicyIds, truncatedReason, sql);
}
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateExecutionResult.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateExecutionResult.java
index e2a2ac582..fad56ad2c 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateExecutionResult.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateExecutionResult.java
@@ -5,7 +5,8 @@
import java.util.UUID;
public record UpdateExecutionResult(long rowsAffected, Duration duration,
- Set appliedRowSecurityPolicyIds)
+ Set appliedRowSecurityPolicyIds,
+ String effectiveSql)
implements QueryExecutionResult {
public UpdateExecutionResult {
@@ -14,6 +15,12 @@ public record UpdateExecutionResult(long rowsAffected, Duration duration,
}
public UpdateExecutionResult(long rowsAffected, Duration duration) {
- this(rowsAffected, duration, Set.of());
+ this(rowsAffected, duration, Set.of(), null);
+ }
+
+ /** Pre-#937 canonical shape — kept so published engine plugins stay binary-compatible. */
+ public UpdateExecutionResult(long rowsAffected, Duration duration,
+ Set appliedRowSecurityPolicyIds) {
+ this(rowsAffected, duration, appliedRowSecurityPolicyIds, null);
}
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutor.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutor.java
index 121ce3641..07960ec71 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutor.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutor.java
@@ -114,6 +114,7 @@ private QueryExecutionResult executeInternal(QueryExecutionRequest request,
}
var rewrite = rowSecurityRewriter.rewrite(request.sql(), request.rowSecurityPredicates(),
request.softDeleteDirectives());
+ String effectiveSql = effectiveSql(rewrite, request.sql());
// SELECT result cache (AF-457): keyed over the RLS-rewritten SQL + binds + mask/restriction
// directives + row cap, so security scope is part of the key. SELECTs whose referenced
// tables are unknown are never cached (no write-invalidation coverage).
@@ -127,7 +128,7 @@ private QueryExecutionResult executeInternal(QueryExecutionRequest request,
var hit = resultCache.get(request.datasourceId(), cacheKey, durationSince(start));
if (hit.isPresent()) {
observation.lowCardinalityKeyValue("cache", "hit");
- return hit.get();
+ return hit.get().withEffectiveSql(effectiveSql);
}
}
observation.lowCardinalityKeyValue("cache", cacheable ? "miss" : "off");
@@ -143,13 +144,13 @@ private QueryExecutionResult executeInternal(QueryExecutionRequest request,
execProps.maxResultBytes(), descriptor.dbType(), start,
request.restrictedColumns(), request.columnMasks(),
rewrite.appliedPolicyIds());
- if (cacheable && result instanceof SelectExecutionResult select) {
+ if (cacheable) {
resultCache.put(request.datasourceId(), cacheKey,
- request.referencedTables(), resultCache.ttlFor(descriptor), select);
+ request.referencedTables(), resultCache.ttlFor(descriptor), result);
}
- return result;
+ return result.withEffectiveSql(effectiveSql);
}
- var result = runUpdate(statement, start, rewrite.appliedPolicyIds());
+ var result = runUpdate(statement, start, rewrite.appliedPolicyIds(), effectiveSql);
// Any successful write drops cached SELECTs over the touched tables (unknown
// tables ⇒ full-datasource purge, fail-safe for DDL).
resultCache.invalidateTables(request.datasourceId(), request.referencedTables());
@@ -318,7 +319,8 @@ private QueryExecutionResult executeTransactional(QueryExecutionRequest request,
}
throw ex;
}
- return new UpdateExecutionResult(totalAffected, durationSince(start), appliedPolicyIds);
+ return new UpdateExecutionResult(totalAffected, durationSince(start), appliedPolicyIds,
+ effectiveBatchSql(statements, rewrites));
} catch (SQLException ex) {
log.debug("Transactional SQL execution failed for datasource {}: {}",
request.datasourceId(), ex.getMessage());
@@ -390,7 +392,28 @@ private static long sumBatchCounts(long[] counts) {
return sum;
}
- private QueryExecutionResult runSelect(PreparedStatement statement, int effectiveMaxRows,
+ /**
+ * The statement as actually executed (#937): the rewriter already deparses bound predicate
+ * values as {@code ?} placeholders, so this is the redacted form. {@code null} when nothing was
+ * rewritten, so an unrewritten query never stores a copy of its own SQL.
+ */
+ private static String effectiveSql(RowSecurityRewriter.RewriteResult rewrite, String original) {
+ return rewrite.sql().equals(original) ? null : rewrite.sql();
+ }
+
+ /** Whole-batch effective form — every statement, joined — or {@code null} when none changed. */
+ private static String effectiveBatchSql(List statements,
+ RowSecurityRewriter.RewriteResult[] rewrites) {
+ boolean anyRewritten = false;
+ var joined = new java.util.StringJoiner(";\n");
+ for (int i = 0; i < rewrites.length; i++) {
+ anyRewritten |= !rewrites[i].sql().equals(statements.get(i));
+ joined.add(rewrites[i].sql());
+ }
+ return anyRewritten ? joined.toString() : null;
+ }
+
+ private SelectExecutionResult runSelect(PreparedStatement statement, int effectiveMaxRows,
long maxResultBytes, DbType dbType, Instant start,
List restrictedColumns,
List columnMasks,
@@ -407,10 +430,12 @@ private QueryExecutionResult runSelect(PreparedStatement statement, int effectiv
}
private UpdateExecutionResult runUpdate(PreparedStatement statement, Instant start,
- java.util.Set appliedRowSecurityPolicyIds)
+ java.util.Set appliedRowSecurityPolicyIds,
+ String effectiveSql)
throws SQLException {
long affected = statement.executeLargeUpdate();
- return new UpdateExecutionResult(affected, durationSince(start), appliedRowSecurityPolicyIds);
+ return new UpdateExecutionResult(affected, durationSince(start), appliedRowSecurityPolicyIds,
+ effectiveSql);
}
private static void bind(PreparedStatement statement, List
diff --git a/website/sitemap.xml b/website/sitemap.xml
index 19535c387..634e9d648 100644
--- a/website/sitemap.xml
+++ b/website/sitemap.xml
@@ -326,7 +326,7 @@
https://accessflow.io/docs/configuration/audit-compliance/
- 2026-09-15
+ 2026-09-24weekly0.7
From e76775392581f1dfbfecefd91e526493f9d862ea Mon Sep 17 00:00:00 2001
From: Tigran Babloyan
Date: Thu, 24 Sep 2026 15:12:13 +0400
Subject: [PATCH 2/2] =?UTF-8?q?fix(AF-937):=20address=20review=20=E2=80=94?=
=?UTF-8?q?=20soft-delete=20wording,=20positive=20detail=20IT?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../QueryReadControllerIntegrationTest.java | 30 +++++++++++++++++++
docs/07-security.md | 10 +++++--
e2e/tests/row-security-policies.spec.ts | 7 +++--
frontend/src/locales/de.json | 2 +-
frontend/src/locales/en.json | 2 +-
frontend/src/locales/es.json | 2 +-
frontend/src/locales/fr.json | 2 +-
frontend/src/locales/hy.json | 2 +-
frontend/src/locales/ru.json | 2 +-
frontend/src/locales/zh-CN.json | 2 +-
.../pages/admin/AuditorDashboardPage.test.tsx | 2 ++
help-corpus/corpus.jsonl | 4 +--
help-corpus/manifest.json | 14 ++++-----
.../configuration/audit-compliance/index.html | 5 ++--
.../docs/configuration/datasources/index.html | 2 +-
15 files changed, 64 insertions(+), 24 deletions(-)
diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadControllerIntegrationTest.java
index 4fe6ed928..c36cea609 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadControllerIntegrationTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadControllerIntegrationTest.java
@@ -29,6 +29,8 @@
import com.bablsoft.accessflow.workflow.api.QueryNotCancellableException;
import com.bablsoft.accessflow.workflow.api.QueryNotExecutableException;
import com.bablsoft.accessflow.workflow.api.QueryNotReanalyzableException;
+import com.bablsoft.accessflow.workflow.api.QuerySnapshotService;
+import com.bablsoft.accessflow.workflow.api.QuerySnapshotView;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -72,6 +74,7 @@ class QueryReadControllerIntegrationTest {
@MockitoBean QueryResultPersistenceService queryResultPersistenceService;
@MockitoBean QueryCsvExportService queryCsvExportService;
@MockitoBean AuditLogService auditLogService;
+ @MockitoBean QuerySnapshotService querySnapshotService;
private MockMvcTester mvc;
private OrganizationEntity org;
@@ -398,6 +401,33 @@ void getReturnsDetailForSubmitter() {
assertThat(response).bodyJson().doesNotHavePath("$.effective_sql");
}
+ @Test
+ void getSurfacesTheSnapshotEffectiveSqlReadInTheCallersOrganization() {
+ var qid = UUID.randomUUID();
+ var detail = new QueryDetailView(qid, UUID.randomUUID(), "Prod PG", DbType.POSTGRESQL,
+ org.getId(), analyst.getId(), analyst.getEmail(), analyst.getDisplayName(),
+ "SELECT v FROM t", QueryType.SELECT, QueryStatus.EXECUTED, "x", null,
+ 1L, 3, null, null, null, null, null, List.of(), null, Instant.now(), Instant.now());
+ when(queryRequestLookupService.findDetailById(qid, org.getId()))
+ .thenReturn(Optional.of(detail));
+ var effective = "SELECT v FROM (SELECT * FROM t WHERE t.region = ?) t";
+ when(querySnapshotService.find(qid, org.getId())).thenReturn(Optional.of(
+ new QuerySnapshotView(UUID.randomUUID(), qid, org.getId(), UUID.randomUUID(),
+ analyst.getId(), "SELECT v FROM t", QueryType.SELECT, false,
+ DbType.POSTGRESQL, List.of("t"), null, null, "[]", 1L, 3,
+ Instant.now(), Instant.now(), effective)));
+
+ var response = mvc.get().uri("/api/v1/queries/" + qid)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken)
+ .exchange();
+
+ assertThat(response).hasStatus(200);
+ assertThat(response).bodyJson().extractingPath("$.effective_sql").asString()
+ .isEqualTo(effective);
+ // Scoped to the caller's organization — a snapshot is never looked up org-blind.
+ verify(querySnapshotService).find(qid, org.getId());
+ }
+
@Test
void getReturns404WhenNonAdminCallerIsNotSubmitter() {
var qid = UUID.randomUUID();
diff --git a/docs/07-security.md b/docs/07-security.md
index 7d682cfb9..ce62a5d7c 100644
--- a/docs/07-security.md
+++ b/docs/07-security.md
@@ -1000,8 +1000,14 @@ on a table — a primary access boundary at the row grain, enforced in the proxy
deleted. It keeps every predicate value as its `?` placeholder — storing the bound values would copy
user attributes and group ids into the audit trail — and is `NULL` when nothing was rewritten.
Engine-plugin datasources (MongoDB, Redis, …) splice filters into native commands and so store no
- effective statement; their applied policy ids remain the audit record. Readable on
- `GET /queries/{id}` (submitter or `QUERY_VIEW_ALL`) and in the signed regulatory-audit-trail export.
+ effective statement; their applied policy ids remain the audit record, as they do for members of a
+ request group, which executes without a snapshot. Readable on `GET /queries/{id}` (submitter or
+ `QUERY_VIEW_ALL`) and in the signed regulatory-audit-trail export.
+ **The submitter sees the predicate's shape, not its values** — deliberately, per #937's "behind the
+ existing permissions": which column filters them, the operator, how many `?` an `IN` list carries
+ (so how many group / attribute values they hold), and an always-false `1 = 0` when their attribute
+ did not resolve. Before #937 the policy text was admin-only. The values themselves never leave the
+ proxy.
### Per-table row-limit policies (#934)
diff --git a/e2e/tests/row-security-policies.spec.ts b/e2e/tests/row-security-policies.spec.ts
index 49bfb41a1..6469cd474 100644
--- a/e2e/tests/row-security-policies.spec.ts
+++ b/e2e/tests/row-security-policies.spec.ts
@@ -185,10 +185,11 @@ test.describe.serial('row-level security policies (AF-380)', () => {
await page.goto(`/queries/${scopedQueryId}`);
const sqlView = page.getByTestId('query-effective-sql');
await expect(sqlView).toBeVisible({ timeout: 15_000 });
- // AntD Segmented hides its radio inputs — click the visible item label.
- await sqlView.locator('.ant-segmented-item', { hasText: 'Effective' }).click();
+ // AntD Segmented hides its radio inputs — click the visible label. `exact` because the
+ // hint line below the toggle also mentions "Effective SQL".
+ await sqlView.getByText('Effective', { exact: true }).click();
await expect(sqlView.locator('pre')).toContainText('email = ?');
- await sqlView.locator('.ant-segmented-item', { hasText: 'Diff' }).click();
+ await sqlView.getByText('Diff', { exact: true }).click();
await expect(sqlView.getByTestId('sql-diff-view')).toBeVisible();
});
diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json
index 2b1f588d1..0c69ae1ff 100644
--- a/frontend/src/locales/de.json
+++ b/frontend/src/locales/de.json
@@ -861,7 +861,7 @@
"view_submitted": "Eingereicht",
"view_effective": "Effektiv",
"view_diff": "Vergleich",
- "hint": "Effektives SQL ist die Anweisung, wie sie tatsächlich ausgeführt wurde, mit eingefügten Zeilensicherheits-Prädikaten. Gebundene Werte werden als ? angezeigt und nie gespeichert.",
+ "hint": "Effektives SQL ist die Anweisung, wie sie tatsächlich ausgeführt wurde, mit angewendeten Zeilensicherheits-Filtern und Soft-Delete-Umschreibungen. Gebundene Werte werden als ? angezeigt und nie gespeichert.",
"diff_submitted_label": "Eingereichtes SQL",
"diff_effective_label": "Effektives SQL"
}
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 227a21994..e7040441f 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -861,7 +861,7 @@
"view_submitted": "Submitted",
"view_effective": "Effective",
"view_diff": "Diff",
- "hint": "Effective SQL is the statement as it actually ran, with row-security predicates spliced in. Bound values are shown as ? and never stored.",
+ "hint": "Effective SQL is the statement as it actually ran, with row-security filters and soft-delete rewrites applied. Bound values are shown as ? and never stored.",
"diff_submitted_label": "Submitted SQL",
"diff_effective_label": "Effective SQL"
}
diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json
index 3ce21e9f3..379540550 100644
--- a/frontend/src/locales/es.json
+++ b/frontend/src/locales/es.json
@@ -861,7 +861,7 @@
"view_submitted": "Enviado",
"view_effective": "Efectivo",
"view_diff": "Diferencias",
- "hint": "El SQL efectivo es la sentencia tal como se ejecutó realmente, con los predicados de seguridad de filas insertados. Los valores enlazados se muestran como ? y nunca se almacenan.",
+ "hint": "El SQL efectivo es la sentencia tal como se ejecutó realmente, con los filtros de seguridad de filas y las reescrituras de borrado lógico aplicados. Los valores enlazados se muestran como ? y nunca se almacenan.",
"diff_submitted_label": "SQL enviado",
"diff_effective_label": "SQL efectivo"
}
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index 0e338978e..49cc88481 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -861,7 +861,7 @@
"view_submitted": "Soumis",
"view_effective": "Effectif",
"view_diff": "Différences",
- "hint": "Le SQL effectif est l’instruction telle qu’elle a réellement été exécutée, avec les prédicats de sécurité des lignes intégrés. Les valeurs liées sont affichées sous forme de ? et ne sont jamais stockées.",
+ "hint": "Le SQL effectif est l’instruction telle qu’elle a réellement été exécutée, avec les filtres de sécurité des lignes et les réécritures de suppression logique appliqués. Les valeurs liées sont affichées sous forme de ? et ne sont jamais stockées.",
"diff_submitted_label": "SQL soumis",
"diff_effective_label": "SQL effectif"
}
diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json
index 2e83ee188..015627a88 100644
--- a/frontend/src/locales/hy.json
+++ b/frontend/src/locales/hy.json
@@ -861,7 +861,7 @@
"view_submitted": "Ներկայացված",
"view_effective": "Փաստացի",
"view_diff": "Տարբերություն",
- "hint": "Փաստացի SQL-ը հրահանգն է այնպես, ինչպես այն իրականում կատարվել է՝ տողերի անվտանգության պայմաններով։ Կապված արժեքները ցուցադրվում են որպես ? և երբեք չեն պահպանվում։",
+ "hint": "Փաստացի SQL-ը հրահանգն է այնպես, ինչպես այն իրականում կատարվել է՝ տողերի անվտանգության զտիչներով և փափուկ ջնջման վերաշարադրումներով։ Կապված արժեքները ցուցադրվում են որպես ? և երբեք չեն պահպանվում։",
"diff_submitted_label": "Ներկայացված SQL",
"diff_effective_label": "Փաստացի SQL"
}
diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json
index ecb8cbeb6..570079077 100644
--- a/frontend/src/locales/ru.json
+++ b/frontend/src/locales/ru.json
@@ -861,7 +861,7 @@
"view_submitted": "Отправленный",
"view_effective": "Фактический",
"view_diff": "Сравнение",
- "hint": "Фактический SQL — это запрос в том виде, в котором он был выполнен, с подставленными предикатами построчной безопасности. Связанные значения показаны как ? и никогда не сохраняются.",
+ "hint": "Фактический SQL — это запрос в том виде, в котором он был выполнен, с применёнными фильтрами построчной безопасности и перезаписью мягкого удаления. Связанные значения показаны как ? и никогда не сохраняются.",
"diff_submitted_label": "Отправленный SQL",
"diff_effective_label": "Фактический SQL"
}
diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json
index d4823646b..aec0ac0e6 100644
--- a/frontend/src/locales/zh-CN.json
+++ b/frontend/src/locales/zh-CN.json
@@ -861,7 +861,7 @@
"view_submitted": "提交的",
"view_effective": "实际执行",
"view_diff": "差异",
- "hint": "实际执行的 SQL 是语句真正运行时的形式,已嵌入行级安全谓词。绑定值显示为 ?,且从不存储。",
+ "hint": "实际执行的 SQL 是语句真正运行时的形式,已应用行级安全过滤和软删除改写。绑定值显示为 ?,且从不存储。",
"diff_submitted_label": "提交的 SQL",
"diff_effective_label": "实际执行的 SQL"
}
diff --git a/frontend/src/pages/admin/AuditorDashboardPage.test.tsx b/frontend/src/pages/admin/AuditorDashboardPage.test.tsx
index 3c2bdb868..1ec915748 100644
--- a/frontend/src/pages/admin/AuditorDashboardPage.test.tsx
+++ b/frontend/src/pages/admin/AuditorDashboardPage.test.tsx
@@ -125,6 +125,8 @@ describe('AuditorDashboardPage', () => {
);
expect(screen.getByText('Effective SQL')).toBeInTheDocument();
expect(screen.getByText('DELETE FROM carts')).toBeInTheDocument();
+ // The row without a rewrite renders the placeholder, never an empty code cell.
+ expect(screen.getByText('—')).toBeInTheDocument();
});
it('exports a signed PDF on button click', async () => {
diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl
index f39ba09e3..95e3d3066 100644
--- a/help-corpus/corpus.jsonl
+++ b/help-corpus/corpus.jsonl
@@ -176,7 +176,7 @@
{"id":"a539deb87f6dd981","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/","anchor":"","title":"What makes the AccessFlow audit log tamper-evident?","section":"Reference","order":0,"tokens":413,"text":"AccessFlow Docs > Reference > Audit & compliance > What makes the AccessFlow audit log tamper-evident?\n\nEvery row is append-only and carries an HMAC-SHA256 hash chained to the row before it, so altering or deleting any entry breaks the chain and is detectable. The database role the application uses has no UPDATE or DELETE privilege on the table — a separate writer role only inserts.\n\nConfigure it. Nothing to switch on — it captures automatically. Review it\nat /admin/audit-log:\n\n/admin/audit-log — filter, paginate, verify the HMAC chain, and export to CSV.\n\n- Filter and search. Narrow by action, resource type, actor user id, or resource id; an optional start/end date pair scopes the window.\n\n- Verify chain. The Verify chain button re-walks every row's HMAC link in order and surfaces the first mismatch — useful as a recurring auditor check.\n\n- Export CSV. Streams the current filter as RFC 4180 CSV with the same columns shown in the UI. Long-running exports respect the same query budget as the table view (use date filters to keep them bounded).\n\nTune it. The chain-signing key defaults to a per-deployment value derived\nfrom ENCRYPTION_KEY; set AUDIT_HMAC_KEY (hex, ≥ 32 bytes)\nexplicitly when you want to manage or rotate it yourself. Inserts run through a dedicated\nAUDIT_DB_USER / AUDIT_DB_PASSWORD role that has no UPDATE / DELETE\nrights on the log."}
{"id":"435841c3edb381aa","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-audit-sinks","anchor":"cfg-audit-sinks","title":"Audit sinks (SIEM & WORM streaming)","section":"Reference","order":0,"tokens":741,"text":"AccessFlow Docs > Reference > Audit & compliance > Audit sinks (SIEM & WORM streaming) (part 1 of 2)\n\nWhat it is. External audit sinks stream the tamper-evident audit log to\nthe systems your SOC already watches — a SIEM, a syslog collector, your own HTTPS\nendpoint — and archive it to write-once (WORM) object storage. Delivery is\nat-least-once off a durable per-sink cursor: a slow or dead destination\nnever blocks audit writes, and each sink retries forever with backoff, so nothing is\nlost while a receiver is down (receivers de-duplicate on the immutable event id). Every\nstreamed event carries its hash-chain links, so an exported window can be verified\nindependently of the database.\n\nConfigure it. Manage sinks at /admin/audit-sinks (requires\nthe AUDIT_SINK_MANAGE permission; admins hold it). Pick one of four types —\nsecret fields are write-only: encrypted at rest and shown masked as\n******** afterwards:\n\n- Splunk HEC — url (the full HTTP Event Collector endpoint) and token (masked); optional index and source.\n\n- Syslog / CEF — host, port, and protocol (TCP or TLS; TLS validates against the system truststore — there is deliberately no skip-verify option). Events arrive as RFC 5424 syslog frames carrying CEF.\n\n- Signed HTTPS batches — url and secret (masked). Batches are JSON arrays signed with the same X-AccessFlow-Signature HMAC-SHA256 contract as webhook notifications.\n\n- S3 Object Lock (WORM) — bucket, region, access_key_id, secret_access_key (masked), and retention_days; optional prefix, custom S3-compatible endpoint, retention_mode (COMPLIANCE, the immutable default, or GOVERNANCE), and segment_max_age. Audit rows are written as periodic JSONL segments under an Object Lock retention, each with a sibling .sig digital signature you can verify offline against the published signing certificate.\n\nThe list shows per-sink delivery health — cursor position, last success, last error,\nconsecutive failures, next retry, and how many events the sink is behind — and a\nTest button that synchronously pushes one synthetic event through the sink (for\nS3 it uploads a small unlocked test object, so trying a sink never creates immutable\ndata).\n\nTune it. ACCESSFLOW_AUDIT_SINKS_DRAIN_INTERVAL (streaming\ncadence, default PT30S), ACCESSFLOW_AUDIT_SINKS_BATCH_SIZE\n(rows per delivery, default 500), and\nACCESSFLOW_AUDIT_SINKS_MAX_BATCHES_PER_TICK (per-sink catch-up cap per\ntick, default 5)."}
{"id":"a88a11d688bbb7ae","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-audit-sinks","anchor":"cfg-audit-sinks","title":"Audit sinks (SIEM & WORM streaming)","section":"Reference","order":1,"tokens":153,"text":"AccessFlow Docs > Reference > Audit & compliance > Audit sinks (SIEM & WORM streaming) (part 2 of 2)\n\nS3 bucket prerequisite. The bucket must be created with versioning and\nObject Lock enabled (aws s3api create-bucket\n--object-lock-enabled-for-bucket) — Object Lock cannot be enabled on an existing\nplain bucket — and the IAM principal needs s3:PutObject and\ns3:PutObjectRetention. COMPLIANCE mode is immutable for\neveryone until the retention expires; GOVERNANCE allows privileged\noverride."}
-{"id":"cef74b4b01c849a8","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#compliance-reports","anchor":"compliance-reports","title":"Compliance reports & signed exports","section":"Reference","order":0,"tokens":439,"text":"AccessFlow Docs > Reference > Audit & compliance > Compliance reports & signed exports\n\nWhat it is. Ready-made compliance reporting with audit-grade exports. Two\npre-built reports answer common auditor questions over a chosen period:\nclassified-data access (which executed queries touched PII / PCI / PHI /\nGDPR / FINANCIAL / SENSITIVE data, joined to your data-classification tags) and a\nregulatory audit trail of DDL / DELETE operations with the approvers'\nnames and, where a row-security policy rewrote the statement, the effective SQL\nthat actually ran (filter values shown as ?, never stored). Use it to hand a regulator or internal auditor evidence they can verify themselves.\n\nConfigure it. Build and export reports from the compliance dashboard at\n/admin/auditor — open to the read-only AUDITOR role and to\nadmins. Each report exports as a digitally signed PDF or CSV that an\nauditor can verify offline against the public key at\n/api/v1/admin/compliance/signing-certificate; every export is itself recorded\nin the audit log with its content hash, so it's tamper-evident\nend to end.\n\nTune it. ACCESSFLOW_COMPLIANCE_MAX_REPORT_PERIOD (largest\nwindow, default P366D) and ACCESSFLOW_COMPLIANCE_MAX_ROWS (row\ncap before a report is marked truncated, default 50000). Signing reuses\nJWT_PRIVATE_KEY — no extra secret required.\n\n/admin/auditor — the read-only Auditor role builds and signs compliance reports over the immutable query snapshots."}
+{"id":"cef74b4b01c849a8","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#compliance-reports","anchor":"compliance-reports","title":"Compliance reports & signed exports","section":"Reference","order":0,"tokens":446,"text":"AccessFlow Docs > Reference > Audit & compliance > Compliance reports & signed exports\n\nWhat it is. Ready-made compliance reporting with audit-grade exports. Two\npre-built reports answer common auditor questions over a chosen period:\nclassified-data access (which executed queries touched PII / PCI / PHI /\nGDPR / FINANCIAL / SENSITIVE data, joined to your data-classification tags) and a\nregulatory audit trail of DDL / DELETE operations with the approvers'\nnames and, where a row-security filter or a soft-delete rule rewrote the statement, the\neffective SQL that actually ran (filter values shown as ?, never\nstored). Use it to hand a regulator or internal auditor evidence they can verify themselves.\n\nConfigure it. Build and export reports from the compliance dashboard at\n/admin/auditor — open to the read-only AUDITOR role and to\nadmins. Each report exports as a digitally signed PDF or CSV that an\nauditor can verify offline against the public key at\n/api/v1/admin/compliance/signing-certificate; every export is itself recorded\nin the audit log with its content hash, so it's tamper-evident\nend to end.\n\nTune it. ACCESSFLOW_COMPLIANCE_MAX_REPORT_PERIOD (largest\nwindow, default P366D) and ACCESSFLOW_COMPLIANCE_MAX_ROWS (row\ncap before a report is marked truncated, default 50000). Signing reuses\nJWT_PRIVATE_KEY — no extra secret required.\n\n/admin/auditor — the read-only Auditor role builds and signs compliance reports over the immutable query snapshots."}
{"id":"0bcd2b6e400f1954","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-lifecycle","anchor":"cfg-lifecycle","title":"Data lifecycle & right-to-erasure","section":"Reference","order":0,"tokens":530,"text":"AccessFlow Docs > Reference > Audit & compliance > Data lifecycle & right-to-erasure\n\nAdmin. Define retention/erasure rules at\n/admin/lifecycle/policies — per datasource, target a table / column set /\nclassification tag with a retention window (ISO-8601, e.g. P30D or\nP7Y) plus arbitrary conditions (a structured, parameter-bound\npredicate builder and a parser-validated raw-WHERE escape hatch — SQL\ndatasources only) and an action: hard-delete, soft-delete,\nor pseudonymize (salted SHA-256 / format-preserving / tokenization), with an\noptional cron schedule. A dry-run preview reports impact\nwithout executing. The scan job stages eligible work (honouring the cron); tune it with\nACCESSFLOW_LIFECYCLE_POLICY_SCAN_INTERVAL (default PT1H). Staged\nruns now execute automatically through the proxy —\nACCESSFLOW_LIFECYCLE_POLICY_EXECUTION_INTERVAL (default PT5M).\n\nAny user can file a right-to-erasure request at\n/lifecycle/erasure using the same rich configuration (subject\nidentifier and/or target table + conditions). It flows through AI-assisted scope detection\nand review-plan-based peer review: any eligible REVIEWER or\nadmin reviews it at /lifecycle/erasure-reviews (per the datasource review plan,\nmulti-stage; the submitter can never approve their own), and stale reviews auto-reject via\nACCESSFLOW_LIFECYCLE_REVIEW_TIMEOUT (default PT168H). Approved\nrequests are executed through the proxy — soft-deleted rows vanish from reads,\nDELETEs become marker updates, aged PII resolves to an irreversible salted hash\nat read time — with tamper-evident proof-of-deletion audit records and a\nretention-adherence compliance export. Tune the executor with\nACCESSFLOW_LIFECYCLE_ERASURE_EXECUTION_INTERVAL (default PT1M)."}
{"id":"e190e14ebe9ed6ca","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-dashboard","anchor":"cfg-dashboard","title":"Personalized dashboard & weekly digest","section":"Reference","order":0,"tokens":275,"text":"AccessFlow Docs > Reference > Audit & compliance > Personalized dashboard & weekly digest\n\nThe default post-login home (/dashboard) is self-scoped — every user sees\nonly their own data, no admin role required: pending approvals as a reviewer, their recent\nqueries with status/risk trend sparklines, an AI optimization-suggestion backlog they can\ndismiss or open in the editor, and their own behavioural-anomaly alerts. Widgets are\ncustomizable (show/hide, collapse, drag-and-drop reorder) and persist per browser. Users can\nexport the week's summary as a digitally signed PDF/CSV on demand, and opt\nin to a weekly email digest delivered to their email and any configured chat\nchannels. The digest job is clustered-safe; tune it with\nACCESSFLOW_DASHBOARD_WEEKLY_DIGEST_POLL_INTERVAL (how often the job wakes,\ndefault P1D) and ACCESSFLOW_DASHBOARD_WEEKLY_DIGEST_PERIOD (minimum\ngap between digests per user, default P7D)."}
{"id":"ff53222e53363d64","path":"website/docs/configuration/auth/index.html","url":"https://accessflow.io/docs/configuration/auth/#cfg-oauth","anchor":"cfg-oauth","title":"OAuth 2.0 / OIDC","section":"Reference","order":0,"tokens":260,"text":"AccessFlow Docs > Reference > Authentication & SSO > OAuth 2.0 / OIDC\n\nThere is a guide for this. Connect single sign-on\nis the step-by-step version for OAuth 2.0 / OIDC, SAML 2.0 and SCIM provisioning. This chapter is the reference behind it.\n\nWhat it is. Single sign-on through an external identity provider, so\npeople log in with accounts they already have instead of an AccessFlow password. Google,\nGitHub, Microsoft, and GitLab are built in; two more tabs cover self-hosted GitHub\nEnterprise and GitLab (self-managed) (you provide the instance base URL, e.g.\nhttps://github.acme.corp, and AccessFlow appends the well-known sub-paths); and\na generic OpenID Connect tab integrates any other OIDC provider (Keycloak, Auth0,\nOkta, Authentik, Zitadel). It all lives in the database, so adding a provider needs no\nrestart."}
@@ -204,7 +204,7 @@
{"id":"14f5f59d8aefa7ee","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":3,"tokens":680,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 4 of 9)\n\nRead replicas & load balancing (optional). On the datasource\nsettings page, the Read replicas card takes any number of replica endpoints\n(JDBC URL plus optional username and password per endpoint — blank credentials reuse\nthe primary's). AccessFlow opens one connection pool per endpoint and load-balances\nevery query classified as SELECT round-robin across the healthy replicas;\nINSERT / UPDATE / DELETE / DDL and transactional BEGIN … COMMIT batches\nalways hit the primary. Replicas must use the same database engine as the primary\n(they reuse the primary's JDBC driver), and credentials are AES-256-GCM encrypted with\nthe same ENCRYPTION_KEY. Per-node health checks (a background prober plus\na circuit breaker) take a failed endpoint out of rotation for a cooldown\n(ACCESSFLOW_PROXY_REPLICA_COOLDOWN, default 30s) and its health shows on\nthe Datasource health dashboard; only when every replica is down does the\nread fall back to the primary, with one DATASOURCE_REPLICA_FALLBACK audit\nrow visible at /admin/audit-log. Click Test replica on any row\nto validate its URL + credentials live without persisting; leaving the password blank\nreuses that endpoint's saved password. Remove every endpoint to disable replica\nrouting. Replica pools reuse the same ACCESSFLOW_PROXY_* connection-pool\ntuning as the primary; the health checks are tuned by the\nACCESSFLOW_PROXY_REPLICA_* variables.\n\nSELECT result caching (optional). The settings page's\nPerformance card opts a datasource into a Redis-backed result cache for\nrepeated identical SELECTs, with a per-datasource TTL (1–86,400 seconds;\nblank uses ACCESSFLOW_PROXY_CACHE_DEFAULT_TTL, default 60s). Caching is\nsecurity-safe by construction — entries are keyed over the row-security-rewritten\nquery and the caller's masking scope, so masking and row-level security always apply —\nand any write executed through AccessFlow to a referenced table (including GDPR\nerasure and retention deletes) immediately invalidates the affected entries. Note that\nwrites made outside AccessFlow are invisible to the cache and are served\nstale until the TTL expires, so pick a TTL that matches how the datasource is written.\nACCESSFLOW_PROXY_CACHE_ENABLED=false switches the feature off\ndeployment-wide."}
{"id":"3b0c42e1a4a75633","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":4,"tokens":709,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 5 of 9)\n\nGrant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), and denied columns. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table.\n\nDenied columns — block instead of mask. A restricted column can still be queried; only its value is hidden. For a column that must never be read at all, list it under Denied columns as table.column or schema.table.column. A query that uses it is refused before it runs:\n\n- What counts as using it. Selecting it, filtering, joining, grouping or sorting on it, or reading its whole table through SELECT *, TABLE t or a whole-row value such as row_to_json(t). Spell out the columns you need instead of *. The table preview on the Schema tab reads every column, so it is refused on a table with a denied column.\n\n- Joins. A column written without its table in a query that joins several tables is refused if any of those tables denies a column of that name. Prefix it with the table to avoid this.\n\n- Deny beats mask. A query that uses a column that is both restricted and denied is refused.\n\n- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column stays denied only while every one of those grants denies it. A grant that denies nothing, including a temporary just-in-time grant, lifts the deny.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources."}
{"id":"6982bd47758e3828","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":5,"tokens":792,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 9)\n\nUsers only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.\n\nSchema explorer & ER diagram. Each datasource also carries\nSchema and ER diagram tabs alongside Configuration /\nPermissions. The schema view introspects the live database (cached and\nrefreshable from the UI) and renders a searchable object tree — one\nfilter matches across schema, table, and column names. Click any table to open a\nsample-data preview: a small, read-only set of rows fetched through the\nsame governance path as a real query, so row-level security filters the rows and column\nmasking redacts sensitive values (masked columns show ***, never the raw\nvalue). The same searchable tree and preview are available in the query editor sidebar.\nThe ER tab lays those tables out as a node-and-edge graph with PK/FK badges and column\ntypes so reviewers and operators can sanity-check what a query is touching without\nleaving AccessFlow.\n\n/datasources//settings → ER diagram. Auto-laid-out via dagre; node positions persist after manual edits.\n\nMasking policies. The datasource Masking tab adds per-column\ndynamic data masking on top of the static restricted-columns masking above. Each\npolicy targets a schema.table.column and picks a strategy —\nfull (***), partial (keep the last N characters),\nhash (stable SHA-256), email (j***@domain), or\nformat-preserving — with an optional reveal-to condition. A query\nsubmitter whose role, group, or user id is listed in reveal to sees the unmasked\nvalue; everyone else sees the strategy output. A live preview shows how a sample value will\nrender. Masking is applied at result-read time before results are serialized or stored, so\nunmasked values never persist, and the ids of the policies that applied are recorded in the\nexecution's audit metadata. Reveal is explicit — there is no implicit admin bypass."}
-{"id":"f146d35d5deed80d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":6,"tokens":757,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 9)\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions.\n\nRow security policies. The datasource Row security tab adds\nrow-level security: per-table predicates the proxy injects into the parsed SQL so a\nscoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for.\nEach policy is a structured column operator value predicate where the value is a\nfixed literal or a :user.* variable — the built-in\n:user.id / :user.email / :user.role /\n:user.groups, or an admin-set per-user attribute (the Attributes\nkey/value editor on Admin → Users). The applies to roles / groups / users\nscope it (empty = everyone, no implicit admin bypass — the inverse of masking's\nreveal to). Values are bound as parameters, never concatenated; an unresolved\nvariable filters out every row (fail-closed); and a query the engine can't safely rewrite\n(a policied table inside a UNION, CTE, sub-select, or join-onto-another-policied-table) is\nrejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit\nmetadata, and the query's detail page keeps the effective SQL — the statement as it\nactually ran, with the policy's filter in place and its values shown as ? — so\nan auditor sees exactly what executed even after the policy is later changed or deleted.\n\n/datasources//settings → Row security. Per-table predicates injected into the parsed SQL; values bound as parameters.\n\nSimulate a policy before you save it. Both the Masking and\nRow security forms have a Simulate button that dry-runs the draft\nagainst this datasource's own past queries, so you see the blast radius first. Pick a\ndate range (up to 90 days) and AccessFlow replays that traffic twice —\nonce against the policies in place today, once with the draft added or replacing the one\nyou are editing — then reports the difference: for masking, which columns would start (or\nstop) being hidden, in how many past queries, and for whom; for row security, which\nqueries would newly come back filtered, come back empty, or be rejected outright because\nthe engine cannot safely apply the predicate to that shape. Redis is the clearest case —\na row rule has no meaning over a key-value store, so the simulation lists exactly the\ncommands the policy would start refusing. The same button sits on the\nrouting policy\nform."}
+{"id":"f146d35d5deed80d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":6,"tokens":755,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 9)\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions.\n\nRow security policies. The datasource Row security tab adds\nrow-level security: per-table predicates the proxy injects into the parsed SQL so a\nscoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for.\nEach policy is a structured column operator value predicate where the value is a\nfixed literal or a :user.* variable — the built-in\n:user.id / :user.email / :user.role /\n:user.groups, or an admin-set per-user attribute (the Attributes\nkey/value editor on Admin → Users). The applies to roles / groups / users\nscope it (empty = everyone, no implicit admin bypass — the inverse of masking's\nreveal to). Values are bound as parameters, never concatenated; an unresolved\nvariable filters out every row (fail-closed); and a query the engine can't safely rewrite\n(a policied table inside a UNION, CTE, sub-select, or join-onto-another-policied-table) is\nrejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit\nmetadata, and the query's detail page keeps the effective SQL — the statement as it\nactually ran, with the policy's filter in place and its values shown as ? — so\nan auditor sees what executed even after the policy is later changed or deleted.\n\n/datasources//settings → Row security. Per-table predicates injected into the parsed SQL; values bound as parameters.\n\nSimulate a policy before you save it. Both the Masking and\nRow security forms have a Simulate button that dry-runs the draft\nagainst this datasource's own past queries, so you see the blast radius first. Pick a\ndate range (up to 90 days) and AccessFlow replays that traffic twice —\nonce against the policies in place today, once with the draft added or replacing the one\nyou are editing — then reports the difference: for masking, which columns would start (or\nstop) being hidden, in how many past queries, and for whom; for row security, which\nqueries would newly come back filtered, come back empty, or be rejected outright because\nthe engine cannot safely apply the predicate to that shape. Redis is the clearest case —\na row rule has no meaning over a key-value store, so the simulation lists exactly the\ncommands the policy would start refusing. The same button sits on the\nrouting policy\nform."}
{"id":"1c4cee538562bb8d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":7,"tokens":580,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 8 of 9)\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it —\nrow rules are worked out on the stored query text alone. It compares policies against\npolicies — today's rules versus the draft — rather than against what actually\nhappened, because a past result may have come from an emergency, a ticket, or a standing\ngrant the draft has no say over. The results name their own limits: roles and group\nmemberships are read as they stand today, masking is matched on the column name alone\n(so a name two tables share can be over-counted), and where an engine cannot work out\noffline what a row rule would do — Cassandra and ScyllaDB need live key information —\nthose queries are listed as unclassifiable rather than counted as unaffected.\nSimulating is always optional; nothing blocks you from saving.\n\nRow limits. The datasource Row limits tab caps how many rows a\nquery may return when it reads a particular table, so two tables on the same database\ncan have different limits and one team can be held tighter than another on the same\ntable. Each policy names a table (and optionally its schema), a maximum number of rows,\nand the applies to roles / groups / users it covers (empty = everyone, admins\nincluded). A row limit can only ever lower the cap: the datasource's\nMax rows per query and any per-user limit on the access grant still apply, and\nthe smallest number wins. A query that joins several limited tables gets the lowest of\ntheir limits. A policy with a schema also catches queries that name the table without\none or with a database name in front, so neither gets anyone more rows. Results that hit\nthe limit are marked as truncated, the table preview obeys the same limit, and when a\npolicy's limit is the one that applied it is recorded in the query's audit entry."}
{"id":"9b654aff5b19dadf","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":8,"tokens":281,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 9 of 9)\n\nExport policies. Masking and row security govern what a user\nsees; the datasource Export policy tab governs what leaves.\nEach policy sets a mode — allow, watermark, row cap, or\ndeny when classified (optionally scoped to specific classifications) — and an\napplies to roles / groups / users target (empty = every exporter, no implicit\nadmin bypass). When several policies apply, the most restrictive wins. The policies gate\nthe signed CSV/PDF result download on the query detail page and the results attachment\non recurring-run emails: a denied exporter sees a disabled export button with the\nreason, a watermarked download carries the exporter, timestamp, and query id baked into\nthe signed bytes (the modal previews the exact stamp), and every export lands in the\naudit log as RESULT_EXPORTED — with an admin notification whenever a\nclassified result leaves."}
{"id":"f31c837889753d51","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-data-classifications","anchor":"cfg-data-classifications","title":"Data classification","section":"Reference","order":0,"tokens":791,"text":"AccessFlow Docs > Reference > Datasources > Data classification (part 1 of 3)\n\nThe datasource Classification tab tags\ntables and columns with one or more data classifications — PII, PCI,\nPHI, GDPR, FINANCIAL, or SENSITIVE — and\nderives stricter handling automatically. Tagging a column\nauto-applies a masking policy from the classification's default strategy\n(PII / GDPR / FINANCIAL → partial, PCI / PHI → full, SENSITIVE → hash), so you don't\nhand-configure masking for every sensitive field; a table-level tag (no column) is\ninformational. A query that references a tagged table gets an automatic AI risk-score\nbump, and a derivation preview suggests a stricter review posture (AI review,\nhuman approval, minimum approvals) aggregated across the datasource's tags — a suggestion\nyou apply on the datasource's review plan, never auto-changed. Tags are immutable\n(create / delete) and audited; deleting a tag keeps the masking policy it derived. The\nclassifications appear as badges in the schema explorer, and Admin → Data\nclassifications (/admin/data-classifications) lists every tag across all\ndatasources as the evidence base for compliance reporting.\n\nAutomated discovery. Instead of tagging hundreds of tables by hand, the\ndatasource Discovery tab opts a datasource into a scheduled scanner that samples\ncolumn data through the same governed sampling path, detects sensitive values with local\nregex + checksum detectors (emails, credit-card numbers with Luhn, US SSNs, IBANs, phone\nnumbers) and — optionally — your bound AI analyzer, then proposes the\nclassification tags in a review worklist. Confirming a finding applies the tag (deriving\nmasking exactly like a manual tag); dismissing suppresses the proposal permanently. Raw\nsampled values never persist (findings store a redacted sample only), and the AI pass\nonly ever sees column names, types, and redacted samples. Configure the per-datasource\nsample size (10–1000 rows, never more than the datasource's row cap) and cadence (1–720 hours), or hit Scan now for an\nimmediate run; scans and decisions land in the audit log\n(DISCOVERY_SCAN_COMPLETED, DISCOVERY_FINDING_CONFIRMED /\n_DISMISSED). Operator knobs:\nACCESSFLOW_DISCOVERY_SCAN_POLL_INTERVAL (PT15M),\nACCESSFLOW_DISCOVERY_SCAN_TIME_BUDGET (PT10M),\nACCESSFLOW_DISCOVERY_SAMPLE_STATEMENT_TIMEOUT (PT10S),\nACCESSFLOW_DISCOVERY_MAX_TABLES_PER_SCAN (200),\nACCESSFLOW_DISCOVERY_MAX_AI_TABLES_PER_SCAN (25),\nACCESSFLOW_DISCOVERY_MAX_NESTED_DEPTH (5),\nACCESSFLOW_DISCOVERY_MAX_NESTED_LEAVES_PER_ROW (100),\nACCESSFLOW_DISCOVERY_STALE_SCANS_BEFORE_EXPIRY (3),\nACCESSFLOW_DISCOVERY_SCAN_LOCK_AT_MOST_FOR (PT30M)."}
diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json
index d75df83cd..3c858a98a 100644
--- a/help-corpus/manifest.json
+++ b/help-corpus/manifest.json
@@ -1,10 +1,10 @@
{
"schemaVersion": 1,
- "corpusVersion": "d4f915554675",
- "generatedAt": "2026-09-24T10:58:15.231Z",
- "sourceCommit": "e310bfdd3c03a446290691da4cd358736a9c9658",
+ "corpusVersion": "d3b6cd6a8de9",
+ "generatedAt": "2026-09-24T11:11:17.030Z",
+ "sourceCommit": "4614328fe393e510b79f671cf91bbbb416e1233a",
"chunkCount": 586,
- "sha256": "d4f915554675adb9598c7601d0fd45c084f49ce5e43da97c422da9fe6780d00e",
+ "sha256": "d3b6cd6a8de98b1e738ff540809911a8a76effe444620108796d7773ce842ce3",
"quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66",
"sources": [
{
@@ -181,7 +181,7 @@
"url": "https://accessflow.io/docs/configuration/audit-compliance/",
"section": "Reference",
"chunks": 7,
- "sha256": "199b675bcfff7830e05c04fda4f19b7db53123934bb978f3c2ec1e46ac8e8298"
+ "sha256": "c5614e71536e35690d3eb0c3f739b2f0cc183f65abde90cbc987c7ae20195581"
},
{
"path": "website/docs/configuration/auth/index.html",
@@ -205,7 +205,7 @@
"url": "https://accessflow.io/docs/configuration/datasources/",
"section": "Reference",
"chunks": 15,
- "sha256": "2c3e2bf64c8d98d2410ddccc82a895efa499077c14bf1585a9fd6e482c3fb6e0"
+ "sha256": "c04ee5df0d61efc86caa3dcea7b651645bcd474af32edd2ea383e64e1b387cbc"
},
{
"path": "website/docs/configuration/notifications/index.html",
@@ -429,7 +429,7 @@
"url": "https://accessflow.io/docs/",
"section": "Navigation",
"chunks": 9,
- "sha256": "6975d96d513c9aa82237a4b0f74c93516da879bb4412bbe0213e268fef64b95a"
+ "sha256": "f472575231cccfe92ec7133e86bf79972329f033f50e217bff9d3484b6061c6f"
}
]
}
diff --git a/website/docs/configuration/audit-compliance/index.html b/website/docs/configuration/audit-compliance/index.html
index 1733e1b14..dc2530b98 100644
--- a/website/docs/configuration/audit-compliance/index.html
+++ b/website/docs/configuration/audit-compliance/index.html
@@ -372,8 +372,9 @@
Compliance reports & signed exports
classified-data access (which executed queries touched PII / PCI / PHI /
GDPR / FINANCIAL / SENSITIVE data, joined to your data-classification tags) and a
regulatory audit trail of DDL / DELETE operations with the approvers'
- names and, where a row-security policy rewrote the statement, the effective SQL
- that actually ran (filter values shown as ?, never stored). Use it to hand a regulator or internal auditor evidence they can verify themselves.
+ names and, where a row-security filter or a soft-delete rule rewrote the statement, the
+ effective SQL that actually ran (filter values shown as ?, never
+ stored). Use it to hand a regulator or internal auditor evidence they can verify themselves.
Configure it. Build and export reports from the compliance dashboard at
diff --git a/website/docs/configuration/datasources/index.html b/website/docs/configuration/datasources/index.html
index 8d503dc04..4757f0374 100644
--- a/website/docs/configuration/datasources/index.html
+++ b/website/docs/configuration/datasources/index.html
@@ -424,7 +424,7 @@
What is a datasource in AccessFlow?
rejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit
metadata, and the query's detail page keeps the effective SQL — the statement as it
actually ran, with the policy's filter in place and its values shown as ? — so
- an auditor sees exactly what executed even after the policy is later changed or deleted.
+ an auditor sees what executed even after the policy is later changed or deleted.