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 binds) throws SQLException { diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotService.java index 920f1342b..a01416e0a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotService.java @@ -18,9 +18,11 @@ public interface QuerySnapshotService { /** * Records the immutable snapshot for an executed query. Idempotent: a no-op when a snapshot already * exists for the query (safe under event redelivery). Never throws to its caller — failures are - * logged and swallowed so snapshot capture cannot disrupt query execution. + * logged and swallowed so snapshot capture cannot disrupt query execution. {@code effectiveSql} is the + * statement as actually executed (row-security / soft-delete rewrite, bound values redacted as + * {@code ?}), or {@code null} when nothing was rewritten (#937). */ - void recordOnExecution(UUID queryRequestId); + void recordOnExecution(UUID queryRequestId, String effectiveSql); /** Loads the snapshot for a query, scoped to the organization. */ Optional find(UUID queryRequestId, UUID organizationId); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotView.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotView.java index c0bc4f951..88171563c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotView.java @@ -11,7 +11,9 @@ * Read-side view of an immutable {@code query_snapshots} row (AF-449). The AI analysis and review * decisions are carried as raw JSON strings (as {@code QueryDetailView} does for {@code issuesJson}) so * this api type stays free of any third-party dependency. {@code referencedTables} is the set of tables - * the snapshotted query touched, used by the replay schema-compatibility gate. + * the snapshotted query touched, used by the replay schema-compatibility gate. {@code effectiveSql} is the + * statement as actually executed with bound values redacted as {@code ?}, or {@code null} when no + * rewrite occurred (#937). */ public record QuerySnapshotView( UUID id, @@ -30,7 +32,8 @@ public record QuerySnapshotView( Long rowsAffected, Integer executionDurationMs, Instant executedAt, - Instant createdAt) { + Instant createdAt, + String effectiveSql) { public QuerySnapshotView { referencedTables = referencedTables == null ? List.of() : List.copyOf(referencedTables); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/events/QueryExecutedEvent.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/events/QueryExecutedEvent.java index 7562dfe6a..57654e5d5 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/events/QueryExecutedEvent.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/events/QueryExecutedEvent.java @@ -9,6 +9,8 @@ * with a runtime failure ({@code finalStatus = FAILED}). Drives the realtime * {@code query.executed} push to the submitter. {@code recurringParentId} is non-null only for * occurrence rows of a recurring series (#627) — it gates the result-delivery notification. + * {@code effectiveSql} is the statement as actually executed — row-security / soft-delete + * rewrite, bound values redacted as {@code ?} — or {@code null} when nothing was rewritten (#937). * *

Published outside any transaction, so consumers must be plain {@code @EventListener}s — * an {@code @ApplicationModuleListener} (AFTER_COMMIT) would silently never fire. @@ -18,11 +20,18 @@ public record QueryExecutedEvent( Long rowsAffected, long durationMs, QueryStatus finalStatus, - UUID recurringParentId) { + UUID recurringParentId, + String effectiveSql) { /** Backward-compatible constructor without the #627 series back-pointer. */ public QueryExecutedEvent(UUID queryRequestId, Long rowsAffected, long durationMs, QueryStatus finalStatus) { - this(queryRequestId, rowsAffected, durationMs, finalStatus, null); + this(queryRequestId, rowsAffected, durationMs, finalStatus, null, null); + } + + /** Backward-compatible constructor without the #937 effective statement. */ + public QueryExecutedEvent(UUID queryRequestId, Long rowsAffected, long durationMs, + QueryStatus finalStatus, UUID recurringParentId) { + this(queryRequestId, rowsAffected, durationMs, finalStatus, recurringParentId, null); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleService.java index 112917478..c47171bc5 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleService.java @@ -348,17 +348,20 @@ private ExecutionOutcome doExecute(QueryRequestSnapshot query, UUID actorUserId, Set appliedMaskingPolicyIds = Set.of(); Set appliedRowSecurityPolicyIds; Set appliedRowLimitPolicyIds = Set.of(); + String effectiveSql; switch (result) { case SelectExecutionResult select -> { rowsAffected = select.rowCount(); appliedRowLimitPolicyIds = bindingRowLimitPolicyIds; appliedMaskingPolicyIds = select.appliedMaskingPolicyIds(); appliedRowSecurityPolicyIds = select.appliedRowSecurityPolicyIds(); + effectiveSql = select.effectiveSql(); persistSelectResult(query.id(), select, durationMs); } case UpdateExecutionResult update -> { rowsAffected = update.rowsAffected(); appliedRowSecurityPolicyIds = update.appliedRowSecurityPolicyIds(); + effectiveSql = update.effectiveSql(); } } var canonicalSql = sqlCanonicalizer.canonicalize(query.sqlText()); @@ -404,7 +407,7 @@ private ExecutionOutcome doExecute(QueryRequestSnapshot query, UUID actorUserId, query.organizationId(), successMetadata); eventPublisher.publishEvent(new QueryExecutedEvent( query.id(), rowsAffected, durationMs, QueryStatus.EXECUTED, - query.recurringParentId())); + query.recurringParentId(), effectiveSql)); return new ExecutionOutcome(query.id(), QueryStatus.EXECUTED, rowsAffected, durationMs); } catch (UnrewritableRowSecurityException | InvalidSqlException ex) { // A structurally unfilterable (or unparseable) query is a client error. For an diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySnapshotService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySnapshotService.java index 2cb5cb740..d87525276 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySnapshotService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySnapshotService.java @@ -37,7 +37,7 @@ class DefaultQuerySnapshotService implements QuerySnapshotService { private final ObjectMapper objectMapper; @Override - public void recordOnExecution(UUID queryRequestId) { + public void recordOnExecution(UUID queryRequestId, String effectiveSql) { try { if (repository.existsByQueryRequestId(queryRequestId)) { return; @@ -55,6 +55,7 @@ public void recordOnExecution(UUID queryRequestId) { return; } var entity = build(query, detail); + entity.setEffectiveSql(effectiveSql); repository.save(entity); } catch (DataIntegrityViolationException ex) { // Lost the UNIQUE(query_request_id) race against a concurrent/redelivered event — fine. diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListener.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListener.java index 68f591923..353eaaca6 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListener.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListener.java @@ -45,7 +45,7 @@ void onQueryExecuted(QueryExecutedEvent event) { return; } try { - querySnapshotService.recordOnExecution(event.queryRequestId()); + querySnapshotService.recordOnExecution(event.queryRequestId(), event.effectiveSql()); } catch (RuntimeException ex) { log.error("Snapshot listener failed for query {}", event.queryRequestId(), ex); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotMapper.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotMapper.java index 628a7c038..8f442569c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotMapper.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotMapper.java @@ -33,6 +33,7 @@ static QuerySnapshotView toView(QuerySnapshotEntity entity) { entity.getRowsAffected(), entity.getExecutionDurationMs(), entity.getExecutedAt(), - entity.getCreatedAt()); + entity.getCreatedAt(), + entity.getEffectiveSql()); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/persistence/entity/QuerySnapshotEntity.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/persistence/entity/QuerySnapshotEntity.java index b50da1dbb..d47d47182 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/persistence/entity/QuerySnapshotEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/persistence/entity/QuerySnapshotEntity.java @@ -53,6 +53,10 @@ public class QuerySnapshotEntity { @Column(name = "sql_text", nullable = false, columnDefinition = "TEXT") private String sqlText; + /** Statement as actually executed, bound values redacted as {@code ?}; null when unrewritten (#937). */ + @Column(name = "effective_sql", updatable = false, columnDefinition = "TEXT") + private String effectiveSql; + @Enumerated(EnumType.STRING) @JdbcType(PostgreSQLEnumJdbcType.class) @Column(name = "query_type", nullable = false, columnDefinition = "query_type") diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponse.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponse.java index 5c17e6948..c05fcc48f 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponse.java @@ -24,6 +24,12 @@ public record QueryDetailResponse( DbType dbType, QueryListItem.SubmitterRef submittedBy, String sqlText, + /** + * The statement as actually executed (#937) — row-security / soft-delete rewrite with bound + * values redacted as {@code ?}, frozen on the query snapshot. Null when no rewrite occurred + * or the query has not executed. + */ + String effectiveSql, QueryType queryType, QueryStatus status, String justification, @@ -98,6 +104,16 @@ public static QueryDetailResponse from(QueryDetailView view, MatchedRoutingPolic AccessGrantView grant, List tickets, boolean includeApprovalPrediction, List sqlReviewFindings) { + return from(view, matched, grant, tickets, includeApprovalPrediction, sqlReviewFindings, + null); + } + + /** @param effectiveSql the snapshot's effective executed statement (#937), or null */ + public static QueryDetailResponse from(QueryDetailView view, MatchedRoutingPolicyView matched, + AccessGrantView grant, List tickets, + boolean includeApprovalPrediction, + List sqlReviewFindings, + String effectiveSql) { return new QueryDetailResponse( view.id(), new QueryListItem.DatasourceRef(view.datasourceId(), view.datasourceName()), @@ -105,6 +121,7 @@ public static QueryDetailResponse from(QueryDetailView view, MatchedRoutingPolic new QueryListItem.SubmitterRef(view.submittedByUserId(), view.submittedByEmail(), view.submittedByDisplayName()), view.sqlText(), + effectiveSql, view.queryType(), view.status(), view.justification(), diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadController.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadController.java index 6a94ad505..5d1f027aa 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadController.java @@ -22,6 +22,8 @@ import com.bablsoft.accessflow.workflow.api.QueryLifecycleService.CancelQueryCommand; import com.bablsoft.accessflow.workflow.api.QueryLifecycleService.ExecuteQueryCommand; import com.bablsoft.accessflow.workflow.api.QueryLifecycleService.ReanalyzeQueryCommand; +import com.bablsoft.accessflow.workflow.api.QuerySnapshotService; +import com.bablsoft.accessflow.workflow.api.QuerySnapshotView; import com.bablsoft.accessflow.workflow.internal.routing.RoutingDecisionService; import org.springframework.security.access.prepost.PreAuthorize; import io.swagger.v3.oas.annotations.Operation; @@ -72,6 +74,7 @@ class QueryReadController { private final QueryTicketService queryTicketService; private final SqlReviewFindingService sqlReviewFindingService; private final SqlReviewFindingRenderer sqlReviewFindingRenderer; + private final QuerySnapshotService querySnapshotService; private final AuditLogService auditLogService; private final ObjectMapper objectMapper; private final MessageSource messageSource; @@ -206,8 +209,10 @@ QueryDetailResponse get(@PathVariable UUID id, Authentication authentication) { var locale = LocaleContextHolder.getLocale(); var findings = SqlReviewFindingDetail.from(sqlReviewFindingService.findByQueryRequest(id), finding -> sqlReviewFindingRenderer.message(finding, locale)); + var effectiveSql = querySnapshotService.find(id, caller.organizationId()) + .map(QuerySnapshotView::effectiveSql).orElse(null); return QueryDetailResponse.from(detail, matchedPolicy, approvingGrant, tickets, - includeApprovalPrediction, findings); + includeApprovalPrediction, findings, effectiveSql); } @PostMapping("/{id}/cancel") diff --git a/backend/src/main/resources/db/migration/V187__add_effective_sql_to_query_snapshots.sql b/backend/src/main/resources/db/migration/V187__add_effective_sql_to_query_snapshots.sql new file mode 100644 index 000000000..eb9283da3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V187__add_effective_sql_to_query_snapshots.sql @@ -0,0 +1,5 @@ +-- Effective executed SQL (#937): the statement as it actually ran — row-security predicates and +-- soft-delete rewrites spliced in, bound values redacted as '?'. NULL when no rewrite occurred, so +-- pre-existing rows and unrewritten queries stay honest. Frozen at execution time: a later policy +-- edit or delete never changes it. +ALTER TABLE query_snapshots ADD COLUMN effective_sql TEXT; diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/api/ComplianceReportRowsTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/api/ComplianceReportRowsTest.java index 25636017a..c6eb63ec1 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/api/ComplianceReportRowsTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/api/ComplianceReportRowsTest.java @@ -23,7 +23,7 @@ void classifiedAccessRowNullListsBecomeEmpty() { void regulatoryRowNullApproversBecomeEmpty() { var row = new RegulatoryAuditTrailRow(UUID.randomUUID(), UUID.randomUUID(), "ds", UUID.randomUUID(), "a@x.com", QueryType.DDL, "CREATE TABLE t (id int)", null, - Instant.EPOCH); + Instant.EPOCH, null); assertThat(row.approvers()).isEmpty(); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/api/ComplianceReportTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/api/ComplianceReportTest.java index a727c31be..3be76e892 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/api/ComplianceReportTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/api/ComplianceReportTest.java @@ -39,7 +39,7 @@ void copiesDefendAgainstCallerMutation() { var report = new ComplianceReport(ComplianceReportType.REGULATORY_AUDIT_TRAIL, UUID.randomUUID(), Instant.EPOCH, Instant.EPOCH, Instant.EPOCH, null, List.of(), mutable, false); mutable.add(new RegulatoryAuditTrailRow(UUID.randomUUID(), UUID.randomUUID(), "ds", - UUID.randomUUID(), "a@x.com", QueryType.DELETE, "DELETE", List.of(), Instant.EPOCH)); + UUID.randomUUID(), "a@x.com", QueryType.DELETE, "DELETE", List.of(), Instant.EPOCH, null)); assertThat(report.auditTrail()).isEmpty(); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/ClassificationJoinerTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/ClassificationJoinerTest.java index 7fd70cefd..b2783b491 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/ClassificationJoinerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/ClassificationJoinerTest.java @@ -24,7 +24,7 @@ class ClassificationJoinerTest { private QuerySnapshotView snapshot(UUID datasourceId, List tables) { return new QuerySnapshotView(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), datasourceId, userId, "SELECT 1", QueryType.SELECT, false, DbType.POSTGRESQL, - tables, null, null, "[]", 5L, 10, executedAt, executedAt); + tables, null, null, "[]", 5L, 10, executedAt, executedAt, null); } private OrganizationDataClassificationView tag(UUID datasourceId, String table, String column, diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/ComplianceCsvWriterTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/ComplianceCsvWriterTest.java index 01dd2b3bb..fefbca765 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/ComplianceCsvWriterTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/ComplianceCsvWriterTest.java @@ -49,12 +49,15 @@ void writesRegulatoryTrailWithApproversAndQuotedSql() { List.of(new RegulatoryAuditTrailRow(UUID.randomUUID(), UUID.randomUUID(), "Prod", UUID.randomUUID(), "a@x.com", QueryType.DELETE, "DELETE FROM users WHERE id = 1", - List.of(new Approver("rev@x.com", "Rev", "APPROVED", t)), t)), + List.of(new Approver("rev@x.com", "Rev", "APPROVED", t)), t, + "DELETE FROM users WHERE id = 1 AND tenant = ?")), false); var csv = new String(writer.write(report), UTF_8); - assertThat(csv).startsWith("query_request_id,datasource_id,datasource_name"); + assertThat(csv).startsWith("query_request_id,datasource_id,datasource_name," + + "submitter_email,query_type,sql_text,effective_sql,approvers,executed_at"); + assertThat(csv).contains("DELETE FROM users WHERE id = 1 AND tenant = ?"); assertThat(csv).contains("DELETE FROM users WHERE id = 1"); assertThat(csv).contains("Rev "); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/CompliancePdfWriterTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/CompliancePdfWriterTest.java index 396962cb7..9df6e5e4e 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/CompliancePdfWriterTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/CompliancePdfWriterTest.java @@ -58,13 +58,15 @@ void rendersRegulatoryTrailPdf() throws IOException { t, t, t, null, List.of(), List.of(new RegulatoryAuditTrailRow(UUID.randomUUID(), UUID.randomUUID(), "ProdDb", UUID.randomUUID(), "alice@example.com", QueryType.DELETE, - "DELETE FROM users", List.of(new Approver("rev@x.com", "Rev", "APPROVED", t)), t)), + "DELETE FROM users", List.of(new Approver("rev@x.com", "Rev", "APPROVED", t)), t, + "UPDATE users SET deleted_at = ?")), false); var pdf = writer.write(report); var text = extractText(pdf); assertThat(text).contains("Regulatory Audit Trail"); + assertThat(text).contains("Effective SQL"); assertThat(text).contains("Rev"); } @@ -87,7 +89,7 @@ void paginatesAcrossManyRowsAndWrapsLongSql() throws IOException { for (int i = 0; i < 80; i++) { rows.add(new RegulatoryAuditTrailRow(UUID.randomUUID(), UUID.randomUUID(), "ProdDb", UUID.randomUUID(), "user" + i + "@example.com", QueryType.DELETE, longSql, - List.of(new Approver("rev@x.com", "Rev", "APPROVED", t)), t)); + List.of(new Approver("rev@x.com", "Rev", "APPROVED", t)), t, null)); } var report = new ComplianceReport(ComplianceReportType.REGULATORY_AUDIT_TRAIL, UUID.randomUUID(), t, t, t, null, List.of(), rows, false); diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultComplianceReportServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultComplianceReportServiceTest.java index 940348a77..1600a7cac 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultComplianceReportServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultComplianceReportServiceTest.java @@ -80,7 +80,8 @@ private UserView user() { private QuerySnapshotView snapshot(QueryType type, List tables) { return new QuerySnapshotView(UUID.randomUUID(), UUID.randomUUID(), orgId, dsId, userId, - "SQL", type, false, DbType.POSTGRESQL, tables, null, null, "[]", 1L, 2, from, from); + "SQL", type, false, DbType.POSTGRESQL, tables, null, null, "[]", 1L, 2, from, from, + "SQL WHERE tenant = ?"); } private OrganizationDataClassificationView tag(String table, DataClassification classification) { @@ -138,6 +139,7 @@ void regulatoryTrailFiltersToDdlAndDeleteAndUsesApprovers() { assertThat(report.type()).isEqualTo(ComplianceReportType.REGULATORY_AUDIT_TRAIL); assertThat(report.auditTrail()).hasSize(1); assertThat(report.auditTrail().getFirst().approvers()).hasSize(1); + assertThat(report.auditTrail().getFirst().effectiveSql()).isEqualTo("SQL WHERE tenant = ?"); assertThat(report.auditTrail().getFirst().approvers().getFirst().email()).isEqualTo("rev@x.com"); assertThat(report.classifiedAccess()).isEmpty(); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultResultExportGovernanceServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultResultExportGovernanceServiceTest.java index c72ae831b..1f4742834 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultResultExportGovernanceServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultResultExportGovernanceServiceTest.java @@ -68,7 +68,7 @@ void setUp() { private QuerySnapshotView snapshot(List referencedTables) { return new QuerySnapshotView(UUID.randomUUID(), queryId, orgId, datasourceId, userId, "SELECT * FROM customers", QueryType.SELECT, false, DbType.POSTGRESQL, - referencedTables, "hash", null, null, 5L, 10, t, t); + referencedTables, "hash", null, null, 5L, 10, t, t, null); } private DataClassificationTagView tag(String table, String column, DataClassification c) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultResultExportServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultResultExportServiceTest.java index b409527d4..189e45262 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultResultExportServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/DefaultResultExportServiceTest.java @@ -88,7 +88,7 @@ auditLogService, properties, new ObjectMapper(), private QuerySnapshotView snapshot(QueryType queryType) { return new QuerySnapshotView(UUID.randomUUID(), queryId, orgId, datasourceId, submitterId, "SELECT * FROM customers", queryType, false, DbType.POSTGRESQL, - List.of("public.customers"), "hash", null, null, 3L, 10, NOW, NOW); + List.of("public.customers"), "hash", null, null, 3L, 10, NOW, NOW, null); } private QueryResultPersistenceService.QueryResultSnapshot result(String rowsJson, diff --git a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/web/ComplianceReportResponseTest.java b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/web/ComplianceReportResponseTest.java index 107a773c2..ce814fcbe 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/web/ComplianceReportResponseTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/compliance/internal/web/ComplianceReportResponseTest.java @@ -46,13 +46,16 @@ void mapsRegulatoryAuditTrailReport() { t, t, t, null, List.of(), List.of(new RegulatoryAuditTrailRow(UUID.randomUUID(), UUID.randomUUID(), "Prod", UUID.randomUUID(), "a@x.com", QueryType.DELETE, "DELETE FROM t", - List.of(new Approver("rev@x.com", "Rev", "APPROVED", t)), t)), + List.of(new Approver("rev@x.com", "Rev", "APPROVED", t)), t, + "DELETE FROM t WHERE tenant = ?")), true); var response = ComplianceReportResponse.from(report); assertThat(response.truncated()).isTrue(); assertThat(response.auditTrail()).hasSize(1); + assertThat(response.auditTrail().getFirst().effectiveSql()) + .isEqualTo("DELETE FROM t WHERE tenant = ?"); assertThat(response.auditTrail().getFirst().approvers().getFirst().displayName()) .isEqualTo("Rev"); assertThat(response.classifiedAccess()).isEmpty(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/SelectExecutionResultTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/SelectExecutionResultTest.java index 5cbbb85c2..d5d0e377b 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/api/SelectExecutionResultTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/SelectExecutionResultTest.java @@ -53,4 +53,25 @@ void withRowSecurityPolicyIdsPreservesTruncatedReason() { assertThat(withIds.truncatedReason()) .isEqualTo(SelectExecutionResult.TRUNCATED_BYTE_LIMIT); } + + @Test + void legacyConstructorsDefaultEffectiveSqlToNull() { + assertThat(new SelectExecutionResult(List.of(), List.of(), 0L, true, Duration.ZERO) + .effectiveSql()).isNull(); + assertThat(new SelectExecutionResult(List.of(), List.of(), 0L, true, Duration.ZERO, + Set.of(), Set.of(), null).effectiveSql()).isNull(); + } + + @Test + void withEffectiveSqlAndWithRowSecurityPolicyIdsPreserveEachOther() { + var id = UUID.randomUUID(); + var result = new SelectExecutionResult(List.of(), List.of(), 0L, true, Duration.ZERO, + Set.of(), Set.of(), SelectExecutionResult.TRUNCATED_ROW_LIMIT) + .withEffectiveSql("SELECT * FROM (SELECT * FROM t WHERE r = ?) t") + .withRowSecurityPolicyIds(Set.of(id)); + + assertThat(result.effectiveSql()).isEqualTo("SELECT * FROM (SELECT * FROM t WHERE r = ?) t"); + assertThat(result.appliedRowSecurityPolicyIds()).containsExactly(id); + assertThat(result.truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_ROW_LIMIT); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/UpdateExecutionResultTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/UpdateExecutionResultTest.java new file mode 100644 index 000000000..595a8c80e --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/UpdateExecutionResultTest.java @@ -0,0 +1,34 @@ +package com.bablsoft.accessflow.core.api; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class UpdateExecutionResultTest { + + @Test + void legacyConstructorsDefaultPolicyIdsToEmptyAndEffectiveSqlToNull() { + var twoArg = new UpdateExecutionResult(3L, Duration.ZERO); + var threeArg = new UpdateExecutionResult(3L, Duration.ZERO, null); + + assertThat(twoArg.appliedRowSecurityPolicyIds()).isEmpty(); + assertThat(twoArg.effectiveSql()).isNull(); + assertThat(threeArg.appliedRowSecurityPolicyIds()).isEmpty(); + assertThat(threeArg.effectiveSql()).isNull(); + } + + @Test + void retainsEffectiveSqlAndPolicyIds() { + var id = UUID.randomUUID(); + + var result = new UpdateExecutionResult(1L, Duration.ZERO, Set.of(id), + "DELETE FROM t WHERE region = ?"); + + assertThat(result.appliedRowSecurityPolicyIds()).containsExactly(id); + assertThat(result.effectiveSql()).isEqualTo("DELETE FROM t WHERE region = ?"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorPostgresIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorPostgresIntegrationTest.java index d4c79bd57..50f008309 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorPostgresIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorPostgresIntegrationTest.java @@ -195,6 +195,33 @@ void rowSecurityInPredicateFiltersSelectedRows() { var result = (SelectExecutionResult) executor.execute(request); assertThat(result.rows()).extracting(row -> row.get(0)).containsExactly("apple", "carrot"); + // #937: the effective statement keeps the placeholders — never the bound policy values. + assertThat(result.effectiveSql()).contains("name IN (?, ?)") + .doesNotContain("apple").doesNotContain("carrot"); + } + + @Test + void unrewrittenSelectReportsNoEffectiveSql() { + var result = (SelectExecutionResult) executor.execute(new QueryExecutionRequest( + datasource.getId(), "SELECT name FROM items", QueryType.SELECT, null, null)); + + assertThat(result.effectiveSql()).isNull(); + } + + @Test + void transactionalRowSecurityRewriteIsReportedPerStatement() { + var directive = new RowSecurityDirective(UUID.randomUUID(), "items", "qty", + RowSecurityOperator.GREATER_THAN, List.of(5)); + var request = new QueryExecutionRequest(datasource.getId(), + "BEGIN; UPDATE items SET name = 'x'; UPDATE items SET qty = qty; COMMIT;", + QueryType.UPDATE, null, null, List.of(), List.of(), List.of(directive), true, + List.of("UPDATE items SET name = 'x'", "UPDATE items SET qty = qty")); + + var result = (UpdateExecutionResult) executor.execute(request); + + assertThat(result.effectiveSql()).startsWith("UPDATE items SET name = 'x' WHERE ") + .contains("qty > ?;\nUPDATE items SET qty = qty WHERE ").endsWith("qty > ?") + .doesNotContain("5"); } @Test @@ -210,6 +237,8 @@ void rowSecurityPredicateFiltersUpdatedRows() { assertThat(result.rowsAffected()).isEqualTo(2); assertThat(result.appliedRowSecurityPolicyIds()).containsExactly(policyId); + assertThat(result.effectiveSql()).startsWith("UPDATE items SET name = 'updated' WHERE ") + .endsWith("qty > ?"); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorTest.java index dd613d461..5de6382d4 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorTest.java @@ -349,6 +349,7 @@ void transactionalBatchesHomogeneousInsertsAndCommits() throws SQLException { var result = (UpdateExecutionResult) executor.execute(request); assertThat(result.rowsAffected()).isEqualTo(2L); + assertThat(result.effectiveSql()).isNull(); verify(batchStmt).setObject(1, 1L); verify(batchStmt).setObject(1, 2L); verify(batchStmt, times(2)).addBatch(); @@ -460,7 +461,7 @@ void selectCacheHitReturnsCachedResultWithoutTouchingJdbc() throws SQLException var result = executor.execute(request); - assertThat(result).isSameAs(cached); + assertThat(result).isEqualTo(cached); verify(poolManager, never()).resolve(datasourceId); verify(resultCache, never()).put(any(), anyString(), any(), any(), any()); } @@ -568,6 +569,84 @@ void updateAppliesRowSecurityPredicateAndBindsParameter() throws SQLException { verify(statement).setObject(eq(1), eq("EU")); assertThat(result.appliedRowSecurityPolicyIds()).containsExactly(policyId); + assertThat(result.effectiveSql()).isEqualTo("UPDATE t SET v = 1 WHERE t.region = ?"); + } + + @Test + void selectRowSecurityRewriteIsReportedAsRedactedEffectiveSql() throws SQLException { + var rs = emptyResultSet(); + when(statement.executeQuery()).thenReturn(rs); + var directive = new RowSecurityDirective(UUID.randomUUID(), "t", "region", + RowSecurityOperator.EQUALS, List.of("EU-secret-tenant")); + var request = new QueryExecutionRequest(datasourceId, "SELECT v FROM t", QueryType.SELECT, + null, null, List.of(), List.of(), List.of(directive), false, null); + + var result = (SelectExecutionResult) executor.execute(request); + + assertThat(result.effectiveSql()).contains("(SELECT * FROM t WHERE region = ?)") + .doesNotContain("EU-secret-tenant"); + } + + @Test + void unrewrittenStatementReportsNullEffectiveSql() throws SQLException { + var rs = emptyResultSet(); + when(statement.executeQuery()).thenReturn(rs); + when(statement.executeLargeUpdate()).thenReturn(1L); + + var select = (SelectExecutionResult) executor.execute(new QueryExecutionRequest( + datasourceId, "SELECT v FROM t", QueryType.SELECT, null, null)); + var update = (UpdateExecutionResult) executor.execute(new QueryExecutionRequest( + datasourceId, "UPDATE t SET v = 1", QueryType.UPDATE, null, null)); + + assertThat(select.effectiveSql()).isNull(); + assertThat(update.effectiveSql()).isNull(); + } + + @Test + void softDeleteRewriteIsReportedAsEffectiveSql() throws SQLException { + when(statement.executeLargeUpdate()).thenReturn(1L); + var softDelete = new com.bablsoft.accessflow.core.api.SoftDeleteDirective( + UUID.randomUUID(), "t", "deleted_at"); + + var result = (UpdateExecutionResult) executor.execute(new QueryExecutionRequest( + datasourceId, "DELETE FROM t WHERE id = 1", QueryType.DELETE, null, null, + List.of(), List.of(), List.of(), false, null, List.of(softDelete), + java.util.Set.of("t"))); + + assertThat(result.effectiveSql()).startsWith("UPDATE t SET deleted_at = CURRENT_TIMESTAMP"); + } + + @Test + void selectCacheHitCarriesTheEffectiveSqlOfThisExecution() throws SQLException { + var cached = new SelectExecutionResult(List.of(), List.of(), 0, false, Duration.ZERO); + when(resultCache.enabledFor(any())).thenReturn(true); + when(resultCache.get(eq(datasourceId), anyString(), any(Duration.class))) + .thenReturn(Optional.of(cached)); + var directive = new RowSecurityDirective(UUID.randomUUID(), "t", "region", + RowSecurityOperator.EQUALS, List.of("EU")); + + var result = (SelectExecutionResult) executor.execute(new QueryExecutionRequest( + datasourceId, "SELECT v FROM t", QueryType.SELECT, null, null, List.of(), + List.of(), List.of(directive), false, null, List.of(), java.util.Set.of("t"))); + + assertThat(result.effectiveSql()).contains("region = ?"); + } + + @Test + void transactionalBatchJoinsEveryStatementWhenAnyWasRewritten() throws SQLException { + when(statement.executeLargeUpdate()).thenReturn(1L); + var directive = new RowSecurityDirective(UUID.randomUUID(), "t", "region", + RowSecurityOperator.EQUALS, List.of("EU-secret-tenant")); + + var result = (UpdateExecutionResult) executor.execute(new QueryExecutionRequest( + datasourceId, + "BEGIN; UPDATE t SET v = 1; DELETE FROM u WHERE id = 2; COMMIT;", + QueryType.UPDATE, null, null, List.of(), List.of(), List.of(directive), true, + List.of("UPDATE t SET v = 1", "DELETE FROM u WHERE id = 2"))); + + assertThat(result.effectiveSql()) + .isEqualTo("UPDATE t SET v = 1 WHERE t.region = ?;\nDELETE FROM u WHERE id = 2") + .doesNotContain("EU-secret-tenant"); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotViewTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotViewTest.java index fb9bf4659..66384f5ae 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotViewTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/api/QuerySnapshotViewTest.java @@ -18,7 +18,7 @@ private QuerySnapshotView view(List referencedTables) { return new QuerySnapshotView(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "SELECT 1", QueryType.SELECT, false, DbType.POSTGRESQL, referencedTables, "hash", null, "[]", 1L, 10, - Instant.now(), Instant.now()); + Instant.now(), Instant.now(), null); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java index 84e9f6797..1caba45f3 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java @@ -551,7 +551,8 @@ void executeUpdateDoesNotPersistResultRows() { when(queryRequestLookupService.findById(queryId)) .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.UPDATE))); when(queryExecutor.execute(any())) - .thenReturn(new UpdateExecutionResult(7L, Duration.ofMillis(40))); + .thenReturn(new UpdateExecutionResult(7L, Duration.ofMillis(40), + java.util.Set.of(), "UPDATE t SET v = 1 WHERE region = ?")); var outcome = service.execute(new ExecuteQueryCommand(queryId, submitterId, organizationId, false)); @@ -559,6 +560,10 @@ void executeUpdateDoesNotPersistResultRows() { assertThat(outcome.status()).isEqualTo(QueryStatus.EXECUTED); assertThat(outcome.rowsAffected()).isEqualTo(7L); verify(queryResultPersistenceService, never()).save(any()); + var event = ArgumentCaptor.forClass(QueryExecutedEvent.class); + verify(eventPublisher).publishEvent(event.capture()); + assertThat(event.getValue().effectiveSql()) + .isEqualTo("UPDATE t SET v = 1 WHERE region = ?"); } @Test @@ -841,7 +846,8 @@ void executeScheduledFiresExecutionWhenDue() { when(queryExecutor.execute(any())).thenReturn(new SelectExecutionResult( List.of(new ResultColumn("c", 4, "int4")), List.of(List.of(1)), - 1L, false, Duration.ofMillis(11))); + 1L, false, Duration.ofMillis(11)) + .withEffectiveSql("SELECT 1 FROM (SELECT * FROM t WHERE region = ?) t")); service.executeScheduled(queryId); @@ -849,6 +855,8 @@ void executeScheduledFiresExecutionWhenDue() { var event = ArgumentCaptor.forClass(QueryExecutedEvent.class); verify(eventPublisher).publishEvent(event.capture()); assertThat(event.getValue().finalStatus()).isEqualTo(QueryStatus.EXECUTED); + assertThat(event.getValue().effectiveSql()) + .isEqualTo("SELECT 1 FROM (SELECT * FROM t WHERE region = ?) t"); var auditCaptor = ArgumentCaptor.forClass(AuditEntry.class); verify(auditLogService).record(auditCaptor.capture()); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayServiceTest.java index 0bca02f8f..8ff43a7cf 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayServiceTest.java @@ -77,7 +77,7 @@ private ReplayCommand command(boolean isAdmin) { private QuerySnapshotView snapshot(DbType dbType, List referenced) { return new QuerySnapshotView(UUID.randomUUID(), originalQueryId, orgId, sourceDsId, userId, "SELECT * FROM users", QueryType.SELECT, false, dbType, referenced, "src-hash", - "{}", "[]", 3L, 9, Instant.now(), Instant.now()); + "{}", "[]", 3L, 9, Instant.now(), Instant.now(), null); } private DatasourceView target(DbType dbType, boolean active) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySnapshotServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySnapshotServiceTest.java index 21f984c07..cc1d8f275 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySnapshotServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySnapshotServiceTest.java @@ -108,11 +108,13 @@ void recordsSnapshotWithAllFields() { Set.of("public.users"))); when(datasourceAdminService.introspectSchemaForSystem(dsId, orgId)).thenReturn(schema()); - service.recordOnExecution(queryId); + service.recordOnExecution(queryId, "SELECT * FROM (SELECT * FROM users WHERE id = ?) users"); var captor = ArgumentCaptor.forClass(QuerySnapshotEntity.class); verify(repository).save(captor.capture()); var saved = captor.getValue(); + assertThat(saved.getEffectiveSql()) + .isEqualTo("SELECT * FROM (SELECT * FROM users WHERE id = ?) users"); assertThat(saved.getQueryRequestId()).isEqualTo(queryId); assertThat(saved.getOrganizationId()).isEqualTo(orgId); assertThat(saved.getDatasourceId()).isEqualTo(dsId); @@ -132,7 +134,7 @@ void recordsSnapshotWithAllFields() { void skipsWhenSnapshotAlreadyExists() { when(repository.existsByQueryRequestId(queryId)).thenReturn(true); - service.recordOnExecution(queryId); + service.recordOnExecution(queryId, null); verify(repository, never()).save(any()); } @@ -142,7 +144,7 @@ void skipsWhenQueryNotFound() { when(repository.existsByQueryRequestId(queryId)).thenReturn(false); when(queryRequestLookupService.findById(queryId)).thenReturn(Optional.empty()); - service.recordOnExecution(queryId); + service.recordOnExecution(queryId, null); verify(repository, never()).save(any()); } @@ -153,7 +155,7 @@ void skipsWhenDetailNotFound() { when(queryRequestLookupService.findById(queryId)).thenReturn(Optional.of(snapshot())); when(queryRequestLookupService.findDetailById(queryId, orgId)).thenReturn(Optional.empty()); - service.recordOnExecution(queryId); + service.recordOnExecution(queryId, null); verify(repository, never()).save(any()); } @@ -164,7 +166,7 @@ void parseFailureStoresNoReferencedTables() { when(queryParser.parse(any(), any())).thenThrow(new RuntimeException("boom")); when(datasourceAdminService.introspectSchemaForSystem(dsId, orgId)).thenReturn(schema()); - service.recordOnExecution(queryId); + service.recordOnExecution(queryId, null); var captor = ArgumentCaptor.forClass(QuerySnapshotEntity.class); verify(repository).save(captor.capture()); @@ -179,7 +181,7 @@ void introspectionFailureStoresNullSchemaHash() { when(datasourceAdminService.introspectSchemaForSystem(dsId, orgId)) .thenThrow(new RuntimeException("db down")); - service.recordOnExecution(queryId); + service.recordOnExecution(queryId, null); var captor = ArgumentCaptor.forClass(QuerySnapshotEntity.class); verify(repository).save(captor.capture()); @@ -193,7 +195,7 @@ void nullAiAnalysisStoresNullJson() { .thenReturn(new SqlParseResult(QueryType.SELECT, "SELECT * FROM users")); when(datasourceAdminService.introspectSchemaForSystem(dsId, orgId)).thenReturn(schema()); - service.recordOnExecution(queryId); + service.recordOnExecution(queryId, null); var captor = ArgumentCaptor.forClass(QuerySnapshotEntity.class); verify(repository).save(captor.capture()); @@ -208,7 +210,7 @@ void uniqueRaceIsSwallowed() { when(datasourceAdminService.introspectSchemaForSystem(dsId, orgId)).thenReturn(schema()); when(repository.save(any())).thenThrow(new DataIntegrityViolationException("dup")); - assertThatCode(() -> service.recordOnExecution(queryId)).doesNotThrowAnyException(); + assertThatCode(() -> service.recordOnExecution(queryId, null)).doesNotThrowAnyException(); } private QuerySnapshotEntity entity(QueryType type) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListenerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListenerIntegrationTest.java index 6d7b4f53e..9ff9db246 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListenerIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListenerIntegrationTest.java @@ -84,6 +84,28 @@ void executedEventWritesSnapshotSynchronously() { assertThat(snapshot.get().getDbType()).isEqualTo(DbType.POSTGRESQL); } + @Test + void executedEventFreezesTheEffectiveStatementOnTheSnapshot() { + var effective = "SELECT 1 FROM (SELECT * FROM t WHERE region = ?) t"; + eventPublisher.publishEvent(new QueryExecutedEvent(query.getId(), 1L, 5L, + QueryStatus.EXECUTED, null, effective)); + + // A redelivered event (or any later write) never rewrites the historical statement (#937). + eventPublisher.publishEvent(new QueryExecutedEvent(query.getId(), 1L, 5L, + QueryStatus.EXECUTED, null, "SELECT 1")); + + var snapshot = snapshotRepository.findByQueryRequestId(query.getId()).orElseThrow(); + assertThat(snapshot.getEffectiveSql()).isEqualTo(effective); + } + + @Test + void unrewrittenExecutionStoresNullEffectiveStatement() { + eventPublisher.publishEvent(new QueryExecutedEvent(query.getId(), 1L, 5L, QueryStatus.EXECUTED)); + + var snapshot = snapshotRepository.findByQueryRequestId(query.getId()).orElseThrow(); + assertThat(snapshot.getEffectiveSql()).isNull(); + } + @Test void failedEventWritesNoSnapshot() { eventPublisher.publishEvent(new QueryExecutedEvent(query.getId(), null, 5L, QueryStatus.FAILED)); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListenerTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListenerTest.java index bcfd8091e..b752a3b66 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListenerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotListenerTest.java @@ -28,7 +28,18 @@ void recordsSnapshotOnExecutedEvent() { listener.onQueryExecuted(new QueryExecutedEvent(queryId, 5L, 12L, QueryStatus.EXECUTED)); - verify(querySnapshotService).recordOnExecution(queryId); + verify(querySnapshotService).recordOnExecution(queryId, null); + } + + @Test + void forwardsEffectiveSqlToSnapshot() { + var queryId = UUID.randomUUID(); + var effective = "SELECT * FROM (SELECT * FROM orders WHERE region = ?) orders"; + + listener.onQueryExecuted(new QueryExecutedEvent(queryId, 5L, 12L, QueryStatus.EXECUTED, + null, effective)); + + verify(querySnapshotService).recordOnExecution(queryId, effective); } @Test @@ -36,13 +47,14 @@ void ignoresFailedExecution() { listener.onQueryExecuted( new QueryExecutedEvent(UUID.randomUUID(), null, 12L, QueryStatus.FAILED)); - verify(querySnapshotService, never()).recordOnExecution(org.mockito.ArgumentMatchers.any()); + verify(querySnapshotService, never()).recordOnExecution( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @Test void swallowsServiceFailure() { var queryId = UUID.randomUUID(); - doThrow(new RuntimeException("boom")).when(querySnapshotService).recordOnExecution(queryId); + doThrow(new RuntimeException("boom")).when(querySnapshotService).recordOnExecution(queryId, null); assertThatCode(() -> listener.onQueryExecuted( new QueryExecutedEvent(queryId, 1L, 1L, QueryStatus.EXECUTED))) diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotMapperTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotMapperTest.java index 527e0b010..bc5c8c437 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotMapperTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QuerySnapshotMapperTest.java @@ -20,6 +20,7 @@ private QuerySnapshotEntity entity() { entity.setDatasourceId(UUID.randomUUID()); entity.setSubmittedBy(UUID.randomUUID()); entity.setSqlText("SELECT 1"); + entity.setEffectiveSql("SELECT 1 WHERE tenant = ?"); entity.setQueryType(QueryType.SELECT); entity.setTransactional(true); entity.setDbType(DbType.POSTGRESQL); @@ -45,6 +46,7 @@ void mapsAllFields() { assertThat(view.datasourceId()).isEqualTo(entity.getDatasourceId()); assertThat(view.submittedBy()).isEqualTo(entity.getSubmittedBy()); assertThat(view.sqlText()).isEqualTo("SELECT 1"); + assertThat(view.effectiveSql()).isEqualTo("SELECT 1 WHERE tenant = ?"); assertThat(view.queryType()).isEqualTo(QueryType.SELECT); assertThat(view.transactional()).isTrue(); assertThat(view.dbType()).isEqualTo(DbType.POSTGRESQL); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponseTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponseTest.java index 509957cd9..5d634b7d4 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponseTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponseTest.java @@ -217,6 +217,19 @@ void sqlReviewFindingsAreCopiedOntoTheResponse() { assertThat(response.sqlReviewFindings()).containsExactly(finding); } + @Test + void effectiveSqlIsNullUnlessSuppliedAndCopiedWhenPresent() { + assertThat(QueryDetailResponse.from(minimalView()).effectiveSql()).isNull(); + assertThat(QueryDetailResponse.from(minimalView(), null, null, null, false, null) + .effectiveSql()).isNull(); + + var response = QueryDetailResponse.from(minimalView(), null, null, null, false, null, + "SELECT * FROM (SELECT * FROM t WHERE region = ?) t"); + + assertThat(response.effectiveSql()) + .isEqualTo("SELECT * FROM (SELECT * FROM t WHERE region = ?) t"); + } + @Test void linkedTicketsAreEmptyForThreeArgOverloadAndNullList() { assertThat(QueryDetailResponse.from(minimalView()).linkedTickets()).isEmpty(); 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 2c7355481..4fe6ed928 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 @@ -394,6 +394,8 @@ void getReturnsDetailForSubmitter() { assertThat(response).bodyJson().extractingPath("$.review_plan_name").asString() .isEqualTo("Prod plan"); assertThat(response).bodyJson().extractingPath("$.approval_timeout_hours").isEqualTo(24); + // No snapshot row (#937) → no effective statement; nulls are omitted from the JSON. + assertThat(response).bodyJson().doesNotHavePath("$.effective_sql"); } @Test diff --git a/docs/03-data-model.md b/docs/03-data-model.md index a07166c37..779f9f895 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -994,6 +994,7 @@ Immutable, sanitized snapshot of an **executed** query (AF-449). Exactly one row | `datasource_id` | UUID NOT NULL — the **source** datasource the query executed against | | `submitted_by` | UUID NOT NULL — the original submitter. No FK: an immutable record must outlive user deletion (like `audit_log.actor_id`) | | `sql_text` | TEXT NOT NULL — the exact SQL captured for replay (AccessFlow inlines literals into `sql_text`; there is no separate bound-parameter store, so this is the complete replay artifact) | +| `effective_sql` | TEXT nullable (V187, #937) — the statement **as it actually executed**: `sql_text` with row-security predicates and soft-delete rewrites spliced in by `RowSecurityRewriter`, with every bound value left as a `?` placeholder (predicate values such as user attributes are never stored). A transactional batch stores every statement's effective form joined by `;` + newline. **NULL** when no rewrite occurred (and on every row written before V187, and always for engine-plugin datasources, which have no redacted form). Written once at insert and mapped `updatable = false`, so editing or deleting a policy later never changes the historical statement. Not used by replay, which re-runs `sql_text` under the policies in force at replay time | | `query_type` | ENUM `query_type` | | `transactional` | BOOLEAN NOT NULL DEFAULT FALSE | | `db_type` | ENUM `db_type` — the source engine; the replay gate requires the target datasource to match | diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 89a8c5a32..db2906317 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -1742,6 +1742,7 @@ Each subsequent row contains the same fields as `QueryListItemView`. `ai_risk_le "db_type": "POSTGRESQL", "submitted_by": { "id": "uuid", "email": "alice@company.com", "display_name": "Alice" }, "sql_text": "UPDATE orders SET status = 'shipped' WHERE id = 123", + "effective_sql": null, "query_type": "UPDATE", "status": "PENDING_REVIEW", "justification": "Customer support ticket #8821", @@ -1855,6 +1856,8 @@ Each subsequent row contains the same fields as `QueryListItemView`. `ai_risk_le `sql_review_findings` are the deterministic SQL review findings recorded for this query at submission (#864, epic #860) — the same per-finding shape as [`POST /sql-review/evaluate`](#post-sql-reviewevaluate--request-body-863), ordered statement → line, with `message` rendered into the caller's `Accept-Language` at read time from the stored `rule_id` + `args` (never from stored text). Always present: an empty array for a datasource the rule catalog does not cover, for an organization with no ruleset bound, and for a clean evaluation. `line_number` is omitted when unknown. A `BLOCK` finding here explains why the query could not auto-approve: it suppressed routing `AUTO_APPROVE`, the grant fast path and the plan's own approvals and forced `PENDING_REVIEW` — it never rejects, and a routing `AUTO_REJECT` still rejects. Findings are evaluated once, at submission, so they are present even when AI analysis was skipped or failed, and are **not** re-evaluated on reanalysis or for recurring occurrences. See the "Submission enforcement (#864)" paragraph of [docs/05-backend.md → Deterministic SQL review rules](05-backend.md#deterministic-sql-review-rules-sqlreview-862). +`effective_sql` is the statement **as it actually executed** (#937), read from the query's immutable `query_snapshots` row: the submitted SQL with row-security predicates and soft-delete rewrites spliced in, every bound value left as a `?` placeholder (predicate values such as user attributes are never stored). For a transactional `BEGIN; … COMMIT;` batch it holds every statement's effective form joined by `;` + newline (the envelope markers are not included). It is omitted (`null`) when no rewrite occurred, when the query has not executed, and always for engine-plugin datasources (MongoDB, Redis, …), which splice filters into native commands and have no redacted form. Frozen at execution time — editing or deleting a policy afterwards never changes it. Visible under the same rule as the rest of the detail (the submitter or a `QUERY_VIEW_ALL` holder). + `scheduled_for` echoes back the optional ISO-8601 instant supplied at submission; `null` for queries that are submitted for immediate review. The recurrence block (#627): `recurrence_rule` / `recurrence_until` echo the submitted series definition (`null` for non-recurring queries). `recurrence_next_run_at` is the next occurrence the `RecurringQueryRunJob` will fire — `null` once the series has completed (`recurrence_until` passed), halted, or been cancelled. `recurrence_halted_reason` is a human-readable reason set only when the series was halted fail-closed (permission lost/expired, SQL no longer parses, datasource deactivated); `null` for an active, completed, or cancelled series. `recurring_parent_id` is set on **occurrence** rows and points at the series parent, letting clients render a "part of a recurring series" banner; occurrences are listed via [`GET /queries/{id}/occurrences`](#get-queriesidoccurrences--response-200). @@ -5764,7 +5767,7 @@ The period is validated: `from`/`to` are required, `from` must be on or before ` } ``` -`GET /admin/compliance/reports/regulatory-audit-trail` has the same envelope but populates `audit_trail` (rows carry `sql_text` and an `approvers` array of `{email, display_name, decision, decided_at}`) and leaves `classified_access` empty. `truncated` is `true` when the snapshot scan hit `accessflow.compliance.max-rows` (default 50,000). +`GET /admin/compliance/reports/regulatory-audit-trail` has the same envelope but populates `audit_trail` (rows carry `sql_text`, `effective_sql` — the redacted statement as executed, #937, omitted when no rewrite occurred — and an `approvers` array of `{email, display_name, decision, decided_at}`) and leaves `classified_access` empty. `truncated` is `true` when the snapshot scan hit `accessflow.compliance.max-rows` (default 50,000). #### GET /admin/compliance/reports/export — Signed export @@ -5778,6 +5781,8 @@ The period is validated: `from`/`to` are required, `from` must be on or before ` - `X-AccessFlow-Content-SHA256` — lowercase hex SHA-256 of the bytes. - `X-AccessFlow-Export-Truncated: true` when the report hit the row cap. +The `REGULATORY_AUDIT_TRAIL` export carries the effective executed statement (#937) next to the submitted one: the CSV has an `effective_sql` column right after `sql_text` (empty when no rewrite occurred), and the PDF an **Effective SQL** column. Bound values are always `?`. + **Tamper-evidence** — every export writes a `COMPLIANCE_REPORT_EXPORTED` audit row (`resource_type=compliance_report`) whose `metadata` carries `report_type`, `format`, `period_from`, `period_to`, `datasource_id`, `row_count`, `truncated`, `content_sha256`, `signature`, and `signature_algorithm`, chaining the export's hash into the tamper-evident audit log. The audit write is integrity-critical — if it fails, the export fails. #### GET /admin/compliance/signing-certificate — Response 200 diff --git a/docs/05-backend.md b/docs/05-backend.md index 244edfffa..d9551a767 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -1330,7 +1330,9 @@ introduces no module cycle. (It cannot live in `audit`: `workflow` already depen query rows, so a report is a stable forensic record of what executed. `QuerySnapshotService.findForPeriod` (`workflow.api`, new) returns snapshots in `[from, to)` on `executed_at`, optionally scoped to a datasource and a `QueryType` set, capped at `accessflow.compliance.max-rows`+1 so the service can flag - truncation. `DefaultComplianceReportService` (`compliance.internal`) validates the period + truncation. Regulatory-audit-trail rows carry the snapshot's `effective_sql` (#937) as well as + `sql_text`; the CSV writer emits it as an `effective_sql` column and the PDF writer as an + **Effective SQL** column. `DefaultComplianceReportService` (`compliance.internal`) validates the period (`InvalidReportPeriodException` → 400 `INVALID_REPORT_PERIOD` when missing/inverted/over `max-report-period`) and dispatches by `ComplianceReportType`. - **Classified-access report.** `ClassificationJoiner` joins each snapshot's `referenced_tables` against @@ -2516,6 +2518,8 @@ Executed queries are otherwise immutable, but there was no first-class way to ta > Why a plain `@EventListener` and not `@ApplicationModuleListener`: `QueryExecutedEvent` is published *outside* a surrounding transaction (the EXECUTED outcome is already committed by `QueryRequestStateService` before the event fires), so an `AFTER_COMMIT` transactional listener would be silently skipped when no transaction is active — the snapshot would never be written. A synchronous listener fires unconditionally, reads the now-committed query / AI / decision rows via fresh transactions, and guarantees the snapshot exists the moment `execute()` returns, so an immediate replay never races a missing snapshot. +**Effective executed SQL (#937).** The snapshot also freezes the statement *as it actually ran*. `RowSecurityRewriter.RewriteResult.sql()` is already the redacted form — predicate values are `JdbcParameter`s that deparse as `?`, with the values held apart in `binds` — so `DefaultQueryExecutor` carries it out unchanged on the new `effectiveSql` component of `core.api.SelectExecutionResult` / `UpdateExecutionResult` (`null` when the rewrite returned the submitted SQL untouched, i.e. no row-security policy and no soft-delete rewrite applied). A transactional batch joins every statement's effective form with `;` + newline, and is `null` when none changed. A SELECT result-cache hit is re-stamped with the effective SQL computed for *this* execution (the cache stores results, not statements). `DefaultQueryLifecycleService.doExecute` copies it onto `QueryExecutedEvent.effectiveSql`, and `QuerySnapshotListener` hands it to `recordOnExecution(queryRequestId, effectiveSql)`, which writes `query_snapshots.effective_sql` (`updatable = false`). Engine plugins return no effective statement — they splice filters into native commands, so there is nothing to redact — and the pre-#937 record constructors are kept so published plugin jars stay binary-compatible. Replay ignores the column: it re-runs `sql_text` under the policies in force at replay time. The value surfaces on `GET /queries/{id}` (`effective_sql`) and in the compliance regulatory audit trail. + **Replay.** `POST /queries/{id}/replay?targetDatasourceId=…` (`QueryReplayController` → `DefaultQueryReplayService`) loads the snapshot (org-scoped; absent → `QuerySnapshotNotFoundException` → 404, which naturally rejects never-executed queries), resolves the target datasource (its own org-scoped not-found → 404, and enforces the caller's visibility/permission), then validates schema compatibility: - **Engine family** — the target's `db_type` must equal the snapshot's, else `ReplaySchemaIncompatibleException` (422). diff --git a/docs/06-frontend.md b/docs/06-frontend.md index a1f02d650..6aa009571 100644 --- a/docs/06-frontend.md +++ b/docs/06-frontend.md @@ -310,7 +310,7 @@ Available to `REVIEWER` / `ADMIN` (AF-378, AF-567). Mirrors the review hub's `Qu Full detail view for any query: -- SQL text in read-only CodeMirror block with syntax highlighting +- SQL text in read-only CodeMirror block with syntax highlighting. When the executed query's snapshot recorded an `effective_sql` (#937 — the statement as it actually ran, row-security / soft-delete rewrite spliced in, bound values shown as `?`), `pages/queries/QuerySqlView.tsx` adds a `Segmented` **Submitted / Effective / Diff** toggle over the card body (defaulting to Submitted) with a one-line note that bound values are never stored; **Diff** reuses `SqlDiffView` (submitted on the left, effective on the right). With no `effective_sql` the card is the plain SQL block, unchanged - `AiAnalysisAccordion` — expandable section showing risk score, all issues with suggestions - `ApprovalTimeline` — visual timeline of review stages and decisions with reviewer comments. A decision taken under an out-of-office delegation (#622) is attributed as "Bob (on behalf of Alice)", so the timeline never implies the delegator acted themselves. The **Human review** stage lists every approver by display name (falling back to email via `userDisplay`) once the query reaches `APPROVED` / `EXECUTED`; multi-stage chains comma-join the names in decision order. Pending reviews still render "awaiting reviewer", and the `REJECTED` stage continues to surface the last rejecter's name + comment. - Execution result section (if executed): rows affected, duration, timestamp. `QueryResultsTable` reads `column.restricted` from each `QueryResultColumn` returned by `GET /queries/{id}/results`; restricted columns render a lock icon + tooltip in the header and muted styling on cells (the value is already `"***"` from the backend — the frontend never has the raw value). A `Segmented` **Table / JSON** toggle switches between the flattened table and a read-only pretty-printed JSON document view (documents reconstructed from `columns`+`rows` by `src/utils/resultDocuments.ts`); the JSON view is the natural fit for MongoDB results but is available for every engine. Beside the toggle, a policy-driven **Export** control (#626): the server-computed decision (`GET /queries/{id}/results/export-decision` via `src/api/resultExport.ts`) drives a CSV/PDF `Dropdown` when allowed, or a disabled button whose tooltip + `aria-label` carry the denial reason (naming the matched classifications) when denied; the button hides entirely while the decision is unavailable. Downloads stream the signed export through `utils/downloadBlob.ts` and surface a warning toast when `X-AccessFlow-Export-Truncated` was set. @@ -401,7 +401,7 @@ immediately. ### AuditorDashboardPage *(AUDITOR or ADMIN)* — AF-459 -The compliance-reporting dashboard at `/admin/auditor` (lazy-loaded). A `Segmented` control switches between the **Classified data access** and **Regulatory audit trail** reports; an AntD `RangePicker` sets the period (defaults to the last 90 days). Data is fetched with TanStack Query (`api/compliance.ts`, key `complianceKeys.report(type, params)`); results render in a `Table` with a `Skeleton` while loading and an `EmptyState` when no rows match. Two header buttons export the current report as a **signed PDF** or **CSV** (`exportComplianceReport`) — the download is triggered from the response blob, and the returned signature / SHA-256 are surfaced via a success toast (with a truncation warning when the row cap was hit). +The compliance-reporting dashboard at `/admin/auditor` (lazy-loaded). A `Segmented` control switches between the **Classified data access** and **Regulatory audit trail** reports; an AntD `RangePicker` sets the period (defaults to the last 90 days). Data is fetched with TanStack Query (`api/compliance.ts`, key `complianceKeys.report(type, params)`); results render in a `Table` with a `Skeleton` while loading and an `EmptyState` when no rows match. The regulatory audit trail has an **Effective SQL** column (#937) beside **SQL**, showing `—` when no rewrite occurred. Two header buttons export the current report as a **signed PDF** or **CSV** (`exportComplianceReport`) — the download is triggered from the response blob, and the returned signature / SHA-256 are surfaced via a success toast (with a truncation warning when the row cap was hit). ### Over-provisioned access (#625) diff --git a/docs/07-security.md b/docs/07-security.md index bdfc64132..7d682cfb9 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. +- **Effective statement is stored, values redacted (#937).** The rewritten statement that actually ran + is frozen on the query's immutable snapshot (`query_snapshots.effective_sql`), so an auditor sees the + spliced predicate without reconstructing it from policy rows that may since have been edited or + 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. ### Per-table row-limit policies (#934) @@ -1508,7 +1516,7 @@ The `compliance` module produces pre-built compliance reports and signed exports - **Reports are computed from the immutable `query_snapshots` forensic record** (AF-449) — never from live, mutable query rows — so a report reflects exactly what executed. Two reports: **classified-data access** (executed queries joined to `data_classification_tag` by datasource + table name, surfacing which queries touched PII/PCI/PHI/GDPR/FINANCIAL/SENSITIVE objects) and a **regulatory audit trail** of DDL/DELETE operations whose approver names are read from the snapshot's embedded review-decision JSON (forensically correct as of execution time). - **Digital signature.** `GET /api/v1/admin/compliance/reports/export?type=…&format=PDF|CSV` renders the report and returns a **detached RSA signature** (`SHA256withRSA`) over the exact delivered bytes, reusing the deployment's JWT RS256 key pair (`security.api.ExportSignatureService`) — no new secret. The signature, its algorithm, and the content SHA-256 are returned as response headers (`X-AccessFlow-Signature`, `X-AccessFlow-Signature-Algorithm`, `X-AccessFlow-Content-SHA256`). `GET /api/v1/admin/compliance/signing-certificate` publishes the PEM public key so an auditor verifies offline: `openssl dgst -sha256 -verify key.pem -signature sig.bin report.pdf`. - **Hash chained into the audit log.** Every export records a `COMPLIANCE_REPORT_EXPORTED` audit entry (`resource_type=compliance_report`) whose `metadata.content_sha256` and `metadata.signature` capture the exported bytes — so the export's hash is embedded in the tamper-evident HMAC chain and is itself detectable against later edits via the audit verifier. This audit write is **integrity-critical: if it fails, the export fails** (it is not swallowed, unlike the best-effort audit-CSV meta-audit). -- **No new persisted data.** Reports reuse `query_snapshots` (V89) + `data_classification_tag` (V90, whose `idx_dct_org` index was added for this org-wide scan); the only schema change is the `AUDITOR` value added to the `user_role_type` enum (V91). +- **No new persisted data.** Reports reuse `query_snapshots` (V89) + `data_classification_tag` (V90, whose `idx_dct_org` index was added for this org-wide scan); the only schema change is the `AUDITOR` value added to the `user_role_type` enum (V91). The regulatory audit trail also carries each snapshot's `effective_sql` (V187, #937) — the redacted statement as executed — in its JSON rows and both export formats. --- diff --git a/e2e/tests/row-security-policies.spec.ts b/e2e/tests/row-security-policies.spec.ts index 9fa903c37..49bfb41a1 100644 --- a/e2e/tests/row-security-policies.spec.ts +++ b/e2e/tests/row-security-policies.spec.ts @@ -66,7 +66,7 @@ async function runAndFetch( submitterToken: string, adminToken: string, datasourceId: string, -): Promise { +): Promise<{ id: string; body: string }> { const submitted = await submitQueryViaApi( request, submitterToken, @@ -82,7 +82,19 @@ async function runAndFetch( headers: { Authorization: `Bearer ${submitterToken}` }, }); if (!res.ok()) throw new Error(`Fetch results failed: ${res.status()} ${await res.text()}`); - return res.text(); + return { id: submitted.id, body: await res.text() }; +} + +async function fetchEffectiveSql( + request: APIRequestContext, + token: string, + queryId: string, +): Promise { + const res = await request.get(`${apiBase()}/api/v1/queries/${queryId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok()) throw new Error(`Fetch query failed: ${res.status()} ${await res.text()}`); + return ((await res.json()) as { effective_sql?: string }).effective_sql; } test.describe.configure({ timeout: 90_000 }); @@ -93,6 +105,9 @@ test.describe.serial('row-level security policies (AF-380)', () => { let datasource: CreatedDatasource | null = null; let scopedAnalyst: { user: InvitedUser; token: string }; let unscopedAnalyst: { user: InvitedUser; token: string }; + let policyId = ''; + let scopedQueryId = ''; + let scopedEffectiveSql = ''; test.beforeAll(async ({ request }) => { adminToken = await loginViaApi(request, ADMIN_EMAIL, ADMIN_PASSWORD); @@ -126,7 +141,7 @@ test.describe.serial('row-level security policies (AF-380)', () => { // Filter the users table to rows whose email equals the submitter's email_filter // attribute. Applies to all ANALYSTs. - await createRowSecurityPolicyViaApi(request, adminToken, datasource.id, { + const policy = await createRowSecurityPolicyViaApi(request, adminToken, datasource.id, { tableName: 'users', columnName: 'email', operator: 'EQUALS', @@ -134,6 +149,7 @@ test.describe.serial('row-level security policies (AF-380)', () => { valueExpression: ':user.email_filter', appliesToRoles: ['ANALYST'], }); + policyId = policy.id; }); test.afterAll(async ({ request }) => { @@ -145,17 +161,41 @@ test.describe.serial('row-level security policies (AF-380)', () => { // ── 1. Scoped analyst only sees the rows the predicate authorises ────────── test('scoped analyst sees only their own row', async ({ request }) => { if (!datasource) throw new Error('datasource not created in beforeAll'); - const body = await runAndFetch(request, scopedAnalyst.token, adminToken, datasource.id); + const { id, body } = await runAndFetch(request, scopedAnalyst.token, adminToken, datasource.id); + scopedQueryId = id; // Only the analyst's own email passes the predicate; the admin's does not. expect(body).toContain(scopedAnalyst.user.email); expect(body).not.toContain(ADMIN_EMAIL); expect(body).not.toContain(unscopedAnalyst.user.email); }); + // ── 1b. The snapshot records the effective statement, values redacted (#937) ─ + test('query detail shows the effective SQL with the predicate and no bound value', async ({ + page, + request, + }) => { + const effective = await fetchEffectiveSql(request, adminToken, scopedQueryId); + expect(effective).toBeDefined(); + scopedEffectiveSql = effective ?? ''; + expect(scopedEffectiveSql).toMatch(/email = \?/); + // The analyst's attribute value is bound, never written into the audit trail. + expect(scopedEffectiveSql).not.toContain(scopedAnalyst.user.email); + + await login(page, ADMIN_EMAIL, ADMIN_PASSWORD); + 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(); + await expect(sqlView.locator('pre')).toContainText('email = ?'); + await sqlView.locator('.ant-segmented-item', { hasText: 'Diff' }).click(); + await expect(sqlView.getByTestId('sql-diff-view')).toBeVisible(); + }); + // ── 2. Fail-closed: an unresolvable variable yields zero rows ────────────── test('analyst without the attribute sees no rows (fail-closed)', async ({ request }) => { if (!datasource) throw new Error('datasource not created in beforeAll'); - const body = await runAndFetch(request, unscopedAnalyst.token, adminToken, datasource.id); + const { body } = await runAndFetch(request, unscopedAnalyst.token, adminToken, datasource.id); // The :user.email_filter variable resolves to nothing → always-false predicate. expect(body).not.toContain('@'); }); @@ -186,4 +226,16 @@ test.describe.serial('row-level security policies (AF-380)', () => { await expect(page.getByText('Row security policy saved')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('public.demo').first()).toBeVisible({ timeout: 10_000 }); }); + + // ── 4. Deleting the policy never rewrites history (#937) ─────────────────── + test('deleting the policy leaves the stored effective SQL unchanged', async ({ request }) => { + if (!datasource) throw new Error('datasource not created in beforeAll'); + const res = await request.delete( + `${apiBase()}/api/v1/datasources/${datasource.id}/row-security-policies/${policyId}`, + { headers: { Authorization: `Bearer ${adminToken}` } }, + ); + expect(res.status()).toBe(204); + + expect(await fetchEffectiveSql(request, adminToken, scopedQueryId)).toBe(scopedEffectiveSql); + }); }); diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 60b9bd964..2b1f588d1 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -855,7 +855,16 @@ "export_denied_classified_tooltip": "Export verweigert: dieses Ergebnis enthält Spalten mit der Klassifizierung {{classifications}}", "card_sql_review": "SQL-Prüfbefunde", "sql_review_blocked_note_one": "{{count}} blockierende Regel hat beim Einreichen ausgelöst; die Abfrage konnte nicht automatisch freigegeben werden und ging in die manuelle Prüfung.", - "sql_review_blocked_note_other": "{{count}} blockierende Regeln haben beim Einreichen ausgelöst; die Abfrage konnte nicht automatisch freigegeben werden und ging in die manuelle Prüfung." + "sql_review_blocked_note_other": "{{count}} blockierende Regeln haben beim Einreichen ausgelöst; die Abfrage konnte nicht automatisch freigegeben werden und ging in die manuelle Prüfung.", + "effective_sql": { + "view_aria": "SQL-Ansicht", + "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.", + "diff_submitted_label": "Eingereichtes SQL", + "diff_effective_label": "Effektives SQL" + } } }, "reviews": { @@ -3917,6 +3926,7 @@ "col_classifications": "Klassifizierungen", "col_rows_affected": "Zeilen", "col_sql": "SQL", + "col_effective_sql": "Effektives SQL", "col_approvers": "Genehmiger" }, "anomalies": { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 0b3f9dac8..227a21994 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -855,7 +855,16 @@ "export_denied_classified_tooltip": "Export denied: this result contains columns classified as {{classifications}}", "card_sql_review": "SQL review findings", "sql_review_blocked_note_one": "{{count}} blocking rule fired at submission, so this query could not auto-approve and was sent to human review.", - "sql_review_blocked_note_other": "{{count}} blocking rules fired at submission, so this query could not auto-approve and was sent to human review." + "sql_review_blocked_note_other": "{{count}} blocking rules fired at submission, so this query could not auto-approve and was sent to human review.", + "effective_sql": { + "view_aria": "SQL view", + "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.", + "diff_submitted_label": "Submitted SQL", + "diff_effective_label": "Effective SQL" + } } }, "reviews": { @@ -3096,6 +3105,7 @@ "col_classifications": "Classifications", "col_rows_affected": "Rows", "col_sql": "SQL", + "col_effective_sql": "Effective SQL", "col_approvers": "Approvers" }, "enums": { diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index e96729771..3ce21e9f3 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -855,7 +855,16 @@ "export_denied_classified_tooltip": "Exportación denegada: este resultado contiene columnas clasificadas como {{classifications}}", "card_sql_review": "Hallazgos de revisión SQL", "sql_review_blocked_note_one": "{{count}} regla bloqueante se activó al enviar, por lo que la consulta no pudo aprobarse automáticamente y pasó a revisión humana.", - "sql_review_blocked_note_other": "{{count}} reglas bloqueantes se activaron al enviar, por lo que la consulta no pudo aprobarse automáticamente y pasó a revisión humana." + "sql_review_blocked_note_other": "{{count}} reglas bloqueantes se activaron al enviar, por lo que la consulta no pudo aprobarse automáticamente y pasó a revisión humana.", + "effective_sql": { + "view_aria": "Vista de SQL", + "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.", + "diff_submitted_label": "SQL enviado", + "diff_effective_label": "SQL efectivo" + } } }, "reviews": { @@ -3917,6 +3926,7 @@ "col_classifications": "Clasificaciones", "col_rows_affected": "Filas", "col_sql": "SQL", + "col_effective_sql": "SQL efectivo", "col_approvers": "Aprobadores" }, "anomalies": { diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index c70a96fd8..0e338978e 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -855,7 +855,16 @@ "export_denied_classified_tooltip": "Export refusé : ce résultat contient des colonnes classifiées {{classifications}}", "card_sql_review": "Constats de revue SQL", "sql_review_blocked_note_one": "{{count}} règle bloquante s'est déclenchée à la soumission ; la requête n'a pas pu être approuvée automatiquement et a été envoyée en revue humaine.", - "sql_review_blocked_note_other": "{{count}} règles bloquantes se sont déclenchées à la soumission ; la requête n'a pas pu être approuvée automatiquement et a été envoyée en revue humaine." + "sql_review_blocked_note_other": "{{count}} règles bloquantes se sont déclenchées à la soumission ; la requête n'a pas pu être approuvée automatiquement et a été envoyée en revue humaine.", + "effective_sql": { + "view_aria": "Vue SQL", + "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.", + "diff_submitted_label": "SQL soumis", + "diff_effective_label": "SQL effectif" + } } }, "reviews": { @@ -3917,6 +3926,7 @@ "col_classifications": "Classifications", "col_rows_affected": "Lignes", "col_sql": "SQL", + "col_effective_sql": "SQL effectif", "col_approvers": "Approbateurs" }, "anomalies": { diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index 4df63f1fc..2e83ee188 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -855,7 +855,16 @@ "export_denied_classified_tooltip": "Արտահանումը մերժված է. այս արդյունքը պարունակում է {{classifications}} դասակարգմամբ սյունակներ", "card_sql_review": "SQL ստուգման արդյունքներ", "sql_review_blocked_note_one": "Ուղարկելիս {{count}} արգելափակող կանոն է գործել, ուստի հարցումը չէր կարող ինքնահաստատվել և ուղարկվել է մարդու ստուգման։", - "sql_review_blocked_note_other": "Ուղարկելիս {{count}} արգելափակող կանոն է գործել, ուստի հարցումը չէր կարող ինքնահաստատվել և ուղարկվել է մարդու ստուգման։" + "sql_review_blocked_note_other": "Ուղարկելիս {{count}} արգելափակող կանոն է գործել, ուստի հարցումը չէր կարող ինքնահաստատվել և ուղարկվել է մարդու ստուգման։", + "effective_sql": { + "view_aria": "SQL դիտում", + "view_submitted": "Ներկայացված", + "view_effective": "Փաստացի", + "view_diff": "Տարբերություն", + "hint": "Փաստացի SQL-ը հրահանգն է այնպես, ինչպես այն իրականում կատարվել է՝ տողերի անվտանգության պայմաններով։ Կապված արժեքները ցուցադրվում են որպես ? և երբեք չեն պահպանվում։", + "diff_submitted_label": "Ներկայացված SQL", + "diff_effective_label": "Փաստացի SQL" + } } }, "reviews": { @@ -3917,6 +3926,7 @@ "col_classifications": "Դասակարգումներ", "col_rows_affected": "Տողեր", "col_sql": "SQL", + "col_effective_sql": "Փաստացի SQL", "col_approvers": "Հաստատողներ" }, "anomalies": { diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 9b8d523db..ecb8cbeb6 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -855,7 +855,16 @@ "export_denied_classified_tooltip": "Экспорт запрещён: результат содержит колонки с классификацией {{classifications}}", "card_sql_review": "Срабатывания проверки SQL", "sql_review_blocked_note_one": "При отправке сработало {{count}} блокирующее правило, поэтому запрос не был одобрен автоматически и направлен на проверку человеком.", - "sql_review_blocked_note_other": "При отправке сработало {{count}} блокирующих правил, поэтому запрос не был одобрен автоматически и направлен на проверку человеком." + "sql_review_blocked_note_other": "При отправке сработало {{count}} блокирующих правил, поэтому запрос не был одобрен автоматически и направлен на проверку человеком.", + "effective_sql": { + "view_aria": "Просмотр SQL", + "view_submitted": "Отправленный", + "view_effective": "Фактический", + "view_diff": "Сравнение", + "hint": "Фактический SQL — это запрос в том виде, в котором он был выполнен, с подставленными предикатами построчной безопасности. Связанные значения показаны как ? и никогда не сохраняются.", + "diff_submitted_label": "Отправленный SQL", + "diff_effective_label": "Фактический SQL" + } } }, "reviews": { @@ -3917,6 +3926,7 @@ "col_classifications": "Классификации", "col_rows_affected": "Строки", "col_sql": "SQL", + "col_effective_sql": "Фактический SQL", "col_approvers": "Утвердившие" }, "anomalies": { diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 55125bd86..d4823646b 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -855,7 +855,16 @@ "export_denied_classified_tooltip": "导出被拒绝:此结果包含分类为 {{classifications}} 的列", "card_sql_review": "SQL 审查发现", "sql_review_blocked_note_one": "提交时触发了 {{count}} 条阻断性规则,因此该查询无法自动批准,已转交人工审查。", - "sql_review_blocked_note_other": "提交时触发了 {{count}} 条阻断性规则,因此该查询无法自动批准,已转交人工审查。" + "sql_review_blocked_note_other": "提交时触发了 {{count}} 条阻断性规则,因此该查询无法自动批准,已转交人工审查。", + "effective_sql": { + "view_aria": "SQL 视图", + "view_submitted": "提交的", + "view_effective": "实际执行", + "view_diff": "差异", + "hint": "实际执行的 SQL 是语句真正运行时的形式,已嵌入行级安全谓词。绑定值显示为 ?,且从不存储。", + "diff_submitted_label": "提交的 SQL", + "diff_effective_label": "实际执行的 SQL" + } } }, "reviews": { @@ -3917,6 +3926,7 @@ "col_classifications": "分类", "col_rows_affected": "行数", "col_sql": "SQL", + "col_effective_sql": "实际执行的 SQL", "col_approvers": "批准人" }, "anomalies": { diff --git a/frontend/src/pages/admin/AuditorDashboardPage.test.tsx b/frontend/src/pages/admin/AuditorDashboardPage.test.tsx index 43df2b8ed..3c2bdb868 100644 --- a/frontend/src/pages/admin/AuditorDashboardPage.test.tsx +++ b/frontend/src/pages/admin/AuditorDashboardPage.test.tsx @@ -88,6 +88,45 @@ describe('AuditorDashboardPage', () => { ); }); + it('shows the effective executed SQL on the regulatory audit trail', async () => { + const trailRow = { + query_request_id: 'q-2', + datasource_id: 'ds-1', + datasource_name: 'ProdDb', + submitted_by: 'u-1', + submitter_email: 'bob@example.com', + query_type: 'DELETE' as const, + sql_text: 'DELETE FROM orders', + approvers: [], + executed_at: '2026-02-01T10:00:00Z', + }; + fetchComplianceReportMock.mockImplementation(async (type: string) => + type === 'REGULATORY_AUDIT_TRAIL' + ? { + ...classifiedReport(), + type: 'REGULATORY_AUDIT_TRAIL', + classified_access: [], + audit_trail: [ + { ...trailRow, effective_sql: 'DELETE FROM orders WHERE orders.region = ?' }, + { ...trailRow, query_request_id: 'q-3', sql_text: 'DELETE FROM carts' }, + ], + row_count: 2, + } + : classifiedReport(), + ); + + render(wrap()); + await waitFor(() => expect(screen.getByText('alice@example.com')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Regulatory audit trail')); + + await waitFor(() => + expect(screen.getByText('DELETE FROM orders WHERE orders.region = ?')).toBeInTheDocument(), + ); + expect(screen.getByText('Effective SQL')).toBeInTheDocument(); + expect(screen.getByText('DELETE FROM carts')).toBeInTheDocument(); + }); + it('exports a signed PDF on button click', async () => { fetchComplianceReportMock.mockResolvedValue(classifiedReport()); exportComplianceReportMock.mockResolvedValue({ diff --git a/frontend/src/pages/admin/AuditorDashboardPage.tsx b/frontend/src/pages/admin/AuditorDashboardPage.tsx index 191cae126..50f588c67 100644 --- a/frontend/src/pages/admin/AuditorDashboardPage.tsx +++ b/frontend/src/pages/admin/AuditorDashboardPage.tsx @@ -109,6 +109,11 @@ export default function AuditorDashboardPage() { dataIndex: 'sql_text', render: (v: string) => {v}, }, + { + title: t('auditor.col_effective_sql'), + dataIndex: 'effective_sql', + render: (v: string | null | undefined) => (v ? {v} : '—'), + }, { title: t('auditor.col_approvers'), dataIndex: 'approvers', diff --git a/frontend/src/pages/queries/QueryDetailPage.tsx b/frontend/src/pages/queries/QueryDetailPage.tsx index 41a01236c..c0b86e6d0 100644 --- a/frontend/src/pages/queries/QueryDetailPage.tsx +++ b/frontend/src/pages/queries/QueryDetailPage.tsx @@ -37,7 +37,6 @@ import { PageHeader } from '@/components/common/PageHeader'; import { StatusPill } from '@/components/common/StatusPill'; import { RiskPill } from '@/components/common/RiskPill'; import { QueryTypePill } from '@/components/common/QueryTypePill'; -import { SqlBlock } from '@/components/common/SqlBlock'; import { DetailCard } from '@/components/common/DetailCard'; import { ApprovalTimeline, type TimelineStage } from '@/components/review/ApprovalTimeline'; import { CostEstimatePanel } from '@/components/review/CostEstimatePanel'; @@ -73,6 +72,7 @@ import { showApiError } from '@/utils/showApiError'; import { userDisplay } from '@/utils/userDisplay'; import type { LinkedTicketRef, QueryDetail, QueryOccurrence } from '@/types/api'; import { QueryDiffCard } from './QueryDiffCard'; +import { QuerySqlView } from './QuerySqlView'; import { buildTimelineStages } from './buildTimelineStages'; import './query-detail.css'; import { OnBehalfOfTag } from '@/components/common/OnBehalfOfTag'; @@ -573,7 +573,11 @@ export function QueryDetailPage() { ) : (

- +
)} diff --git a/frontend/src/pages/queries/QuerySqlView.test.tsx b/frontend/src/pages/queries/QuerySqlView.test.tsx new file mode 100644 index 000000000..5181cbd88 --- /dev/null +++ b/frontend/src/pages/queries/QuerySqlView.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@/i18n'; + +const { mergeViewMock } = vi.hoisted(() => ({ mergeViewMock: vi.fn() })); + +vi.mock('@codemirror/merge', () => ({ + MergeView: class { + constructor(config: unknown) { + mergeViewMock(config); + } + destroy() {} + }, +})); + +import { QuerySqlView } from './QuerySqlView'; + +const SUBMITTED = 'SELECT * FROM orders'; +const EFFECTIVE = 'SELECT * FROM (SELECT * FROM orders WHERE orders.region = ?) orders'; + +describe('QuerySqlView', () => { + beforeEach(() => { + mergeViewMock.mockReset(); + }); + + it('renders only the submitted SQL when no effective statement was recorded', () => { + const { container } = render(); + + expect(container.querySelector('pre')?.textContent).toBe(SUBMITTED); + expect(screen.queryByTestId('query-effective-sql')).not.toBeInTheDocument(); + expect(screen.queryByText('Effective')).not.toBeInTheDocument(); + }); + + it('switches between submitted, effective and diff views', () => { + const { container } = render( + , + ); + + expect(container.querySelector('pre')?.textContent).toBe(SUBMITTED); + expect(screen.getByText(/Bound values are shown as \?/)).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Effective')); + expect(container.querySelector('pre')?.textContent).toBe(EFFECTIVE); + + fireEvent.click(screen.getByText('Diff')); + expect(screen.getByTestId('sql-diff-view')).toBeInTheDocument(); + expect(container.querySelector('pre')).toBeNull(); + const config = mergeViewMock.mock.calls[0]?.[0] as { a: { doc: string }; b: { doc: string } }; + expect(config.a.doc).toBe(SUBMITTED); + expect(config.b.doc).toBe(EFFECTIVE); + }); +}); diff --git a/frontend/src/pages/queries/QuerySqlView.tsx b/frontend/src/pages/queries/QuerySqlView.tsx new file mode 100644 index 000000000..6cdf2549b --- /dev/null +++ b/frontend/src/pages/queries/QuerySqlView.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Segmented } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { SqlBlock } from '@/components/common/SqlBlock'; +import { SqlDiffView } from '@/components/editor/SqlDiffView'; +import type { DbType } from '@/types/api'; + +type SqlView = 'submitted' | 'effective' | 'diff'; + +interface QuerySqlViewProps { + sql: string; + /** The statement as actually executed (#937); absent when no rewrite occurred. */ + effectiveSql?: string | null; + dbType?: DbType; +} + +/** + * The query detail SQL card body. When the snapshot recorded an effective statement (row-security + * predicates / soft-delete rewrite spliced in, bound values shown as `?`), offers the submitted + * text, the effective text, and a side-by-side diff of the two. + */ +export function QuerySqlView({ sql, effectiveSql, dbType }: QuerySqlViewProps) { + const { t } = useTranslation(); + const [view, setView] = useState('submitted'); + + if (!effectiveSql) { + return ; + } + + return ( +
+ + size="small" + value={view} + onChange={setView} + aria-label={t('queries.detail.effective_sql.view_aria')} + options={[ + { value: 'submitted', label: t('queries.detail.effective_sql.view_submitted') }, + { value: 'effective', label: t('queries.detail.effective_sql.view_effective') }, + { value: 'diff', label: t('queries.detail.effective_sql.view_diff') }, + ]} + /> +
+ {t('queries.detail.effective_sql.hint')} +
+ {view === 'submitted' && } + {view === 'effective' && } + {view === 'diff' && ( + + )} +
+ ); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index fe7682960..52a3ba835 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1400,6 +1400,11 @@ export interface QueryDetail { /** The human an API-key submitter acted for (#874); null for a human submission. */ on_behalf_of?: { id: string; email: string | null } | null; sql_text: string; + /** + * The statement as actually executed (#937) — row-security / soft-delete rewrite with bound + * values redacted as `?`, frozen on the query snapshot. Omitted when no rewrite occurred. + */ + effective_sql?: string | null; query_type: QueryType; status: QueryStatus; justification: string; @@ -2210,6 +2215,8 @@ export interface RegulatoryAuditTrailRow { sql_text: string; approvers: ComplianceApprover[]; executed_at: string; + /** Effective executed statement (#937); omitted when no rewrite occurred. */ + effective_sql?: string | null; } export interface ComplianceReport { diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index cb2ba547e..f39ba09e3 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":397,"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. 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":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":"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":685,"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.\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":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":"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 e00c1822a..d75df83cd 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "2561f6312374", - "generatedAt": "2026-09-24T09:22:48.045Z", - "sourceCommit": "9518af83030350beb8f73fdac7e8731893d5b958", + "corpusVersion": "d4f915554675", + "generatedAt": "2026-09-24T10:58:15.231Z", + "sourceCommit": "e310bfdd3c03a446290691da4cd358736a9c9658", "chunkCount": 586, - "sha256": "2561f63123747e9089d17bd0592b6d09b15856388e9ed7262405df00e882d9f7", + "sha256": "d4f915554675adb9598c7601d0fd45c084f49ce5e43da97c422da9fe6780d00e", "quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66", "sources": [ { @@ -181,7 +181,7 @@ "url": "https://accessflow.io/docs/configuration/audit-compliance/", "section": "Reference", "chunks": 7, - "sha256": "26dabebddf780132b1ca0775ec3894604ca5c71d861d3621d1e387e96eadf383" + "sha256": "199b675bcfff7830e05c04fda4f19b7db53123934bb978f3c2ec1e46ac8e8298" }, { "path": "website/docs/configuration/auth/index.html", @@ -205,7 +205,7 @@ "url": "https://accessflow.io/docs/configuration/datasources/", "section": "Reference", "chunks": 15, - "sha256": "23feb73f450f20f6a0eb8a7ee8b963e5c9e33c8fd61e93fb26b12ed8a9281c6e" + "sha256": "2c3e2bf64c8d98d2410ddccc82a895efa499077c14bf1585a9fd6e482c3fb6e0" }, { "path": "website/docs/configuration/notifications/index.html", @@ -429,7 +429,7 @@ "url": "https://accessflow.io/docs/", "section": "Navigation", "chunks": 9, - "sha256": "45da22212c855b134225f27af03dfd563e807166a3c41a2e1feac4faab6b8f3f" + "sha256": "6975d96d513c9aa82237a4b0f74c93516da879bb4412bbe0213e268fef64b95a" } ] } diff --git a/website/docs/configuration/audit-compliance/index.html b/website/docs/configuration/audit-compliance/index.html index 577099c1e..1733e1b14 100644 --- a/website/docs/configuration/audit-compliance/index.html +++ b/website/docs/configuration/audit-compliance/index.html @@ -64,7 +64,7 @@ "inLanguage": "en", "articleSection": "Configuration", "datePublished": "2026-08-03", - "dateModified": "2026-09-15", + "dateModified": "2026-09-24", "url": "https://accessflow.io/docs/configuration/audit-compliance/", "mainEntityOfPage": "https://accessflow.io/docs/configuration/audit-compliance/", "image": "https://accessflow.io/og-image.png", @@ -272,7 +272,7 @@ Documentation

Audit & compliance.

-

Last updated

+

Last updated

@@ -372,7 +372,8 @@

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. Use it to hand a regulator or internal auditor evidence they can verify themselves. + 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.

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 342041984..8d503dc04 100644 --- a/website/docs/configuration/datasources/index.html +++ b/website/docs/configuration/datasources/index.html @@ -422,7 +422,9 @@

What is a datasource in AccessFlow?

variable filters out every row (fail-closed); and a query the engine can't safely rewrite (a policied table inside a UNION, CTE, sub-select, or join-onto-another-policied-table) is rejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit - metadata. + 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.

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-24 weekly 0.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.