From 9518af83030350beb8f73fdac7e8731893d5b958 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 24 Sep 2026 13:16:09 +0400 Subject: [PATCH 1/4] fix(AF-936): scope schema introspection to the caller's allow-list --- .../accessflow/core/api/AllowedTables.java | 68 +++++++++ .../core/api/DatasourceAdminService.java | 9 ++ .../accessflow/core/api/DeniedColumns.java | 20 +++ .../internal/DatasourceAdminServiceImpl.java | 11 +- .../internal/SchemaViewPermissionFilter.java | 122 +++++++++++++++ .../internal/DatasourcePermissionChecker.java | 38 +---- .../core/api/AllowedTablesTest.java | 45 ++++++ .../core/api/DeniedColumnsTest.java | 26 ++++ .../DatasourceAdminServiceImplTest.java | 96 ++++++++++++ .../SchemaViewPermissionFilterTest.java | 142 ++++++++++++++++++ ...tasourceConnectionTestIntegrationTest.java | 26 ++++ docs/04-api-spec.md | 4 +- docs/05-backend.md | 2 + docs/07-security.md | 7 + docs/13-mcp.md | 2 +- help-corpus/corpus.jsonl | 2 +- help-corpus/manifest.json | 10 +- .../docs/configuration/datasources/index.html | 3 + 18 files changed, 587 insertions(+), 46 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/AllowedTables.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/api/AllowedTablesTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/AllowedTables.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/AllowedTables.java new file mode 100644 index 000000000..ca63d231a --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/AllowedTables.java @@ -0,0 +1,68 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The one matcher behind a permission's {@code allowed_schemas} / {@code allowed_tables}, shared by + * the query gates and the schema view (#936) so what a user can see never disagrees with what they + * can query. Both lists empty means no restriction. + */ +public final class AllowedTables { + + private AllowedTables() { + } + + /** Strips identifier quotes, trims, lowercases and drops blanks. */ + public static List normalize(List raw) { + if (raw == null || raw.isEmpty()) { + return List.of(); + } + var out = new ArrayList(raw.size()); + for (String entry : raw) { + var normalized = normalizeEntry(entry); + if (normalized != null) { + out.add(normalized); + } + } + return List.copyOf(out); + } + + /** The {@link #normalize} rule for one name; {@code null} when blank. */ + public static String normalizeEntry(String entry) { + if (entry == null) { + return null; + } + var stripped = new StringBuilder(entry.length()); + for (int i = 0; i < entry.length(); i++) { + char c = entry.charAt(i); + if (c == '"' || c == '`' || c == '[' || c == ']') { + continue; + } + stripped.append(c); + } + var normalized = stripped.toString().trim().toLowerCase(Locale.ROOT); + return normalized.isEmpty() ? null : normalized; + } + + /** + * Which allow-list entry covers {@code table} — the qualified table itself, or the schema whose + * prefix it carries — or {@code null} when none does. Both lists and {@code table} must already + * be {@link #normalize}d. + */ + public static String coveringEntry(List allowedSchemas, List allowedTables, + String table) { + if (allowedTables.contains(table)) { + return table; + } + int dotIdx = table.indexOf('.'); + if (dotIdx > 0) { + var schema = table.substring(0, dotIdx); + if (allowedSchemas.contains(schema)) { + return schema; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceAdminService.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceAdminService.java index 0244924f2..a6a694e2f 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceAdminService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceAdminService.java @@ -38,8 +38,17 @@ public interface DatasourceAdminService { */ ConnectionTestResult testReplica(UUID id, UUID organizationId, TestReplicaCommand command); + /** + * Introspects the datasource as the caller may see it (#936): an admin gets every table; any + * other caller needs an effective permission and gets only the tables its allow-list covers, + * without its denied columns or foreign keys that would name either. + * + * @throws DatasourceNotFoundException when the caller cannot see the datasource or holds no + * effective permission on it + */ DatabaseSchemaView introspectSchema(UUID id, UUID organizationId, UUID userId, boolean isAdmin); + /** Unfiltered introspection for system-actor paths — never exposed to a user as-is. */ DatabaseSchemaView introspectSchemaForSystem(UUID id, UUID organizationId); List listPermissions(UUID datasourceId, UUID organizationId); diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedColumns.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedColumns.java index 478a7b93b..550748e67 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedColumns.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedColumns.java @@ -129,6 +129,26 @@ public static SortedSet rejectedForWholeTable(List rawDenied, St return rejected(denied, Set.of(ColumnReference.wildcard(Set.of(normalizeEntry(table))))); } + /** + * @return whether the deny list denies {@code column} of the introspected table — the schema + * view (#936) hides such columns from a restricted caller. {@code schema} may be null. + */ + public static boolean deniesColumn(List rawDenied, String schema, String table, + String column) { + var denied = normalize(rawDenied); + var normalizedTable = normalizeEntry(table); + var normalizedColumn = normalizeEntry(column); + if (denied.isEmpty() || normalizedTable == null || normalizedColumn == null) { + return false; + } + var normalizedSchema = normalizeEntry(schema); + var qualified = normalizedSchema == null + ? normalizedTable + : normalizedSchema + "." + normalizedTable; + return !rejected(denied, + Set.of(new ColumnReference(Set.of(qualified), normalizedColumn))).isEmpty(); + } + private static SortedSet rejected(List denied, Set references) { var out = new TreeSet(); diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImpl.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImpl.java index 4043a252a..819f21871 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImpl.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImpl.java @@ -15,6 +15,7 @@ import com.bablsoft.accessflow.core.api.DatasourcePermissionAlreadyExistsException; import com.bablsoft.accessflow.core.api.DatasourcePermissionNotFoundException; import com.bablsoft.accessflow.core.api.DatasourcePermissionView; +import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService; import com.bablsoft.accessflow.core.api.DatasourceView; import com.bablsoft.accessflow.core.api.UserGroupService; import com.bablsoft.accessflow.core.api.DbType; @@ -108,6 +109,7 @@ class DatasourceAdminServiceImpl implements DatasourceAdminService { private final QueryEngineCatalog engineCatalog; private final ApplicationEventPublisher eventPublisher; private final MessageSource messageSource; + private final DatasourceUserPermissionLookupService permissionLookupService; @Override @Transactional(readOnly = true) @@ -578,10 +580,15 @@ private ResolvedDriver resolveDriver(DatasourceEntity entity) { public DatabaseSchemaView introspectSchema(UUID id, UUID organizationId, UUID userId, boolean isAdmin) { var entity = loadInOrganization(id, organizationId); - if (!isAdmin && !datasourceRepository.existsVisibleToUser(id, userId, Instant.now())) { + if (isAdmin) { + return introspect(id, entity); + } + if (!datasourceRepository.existsVisibleToUser(id, userId, Instant.now())) { throw new DatasourceNotFoundException(id); } - return introspect(id, entity); + var permission = permissionLookupService.findFor(userId, id) + .orElseThrow(() -> new DatasourceNotFoundException(id)); + return SchemaViewPermissionFilter.apply(introspect(id, entity), permission); } @Override diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java new file mode 100644 index 000000000..c707b930f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java @@ -0,0 +1,122 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.AllowedTables; +import com.bablsoft.accessflow.core.api.DatabaseSchemaView; +import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; +import com.bablsoft.accessflow.core.api.DeniedColumns; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Narrows an introspected schema to what a restricted caller may query (#936): tables outside the + * allow-list, columns on the deny list, and foreign keys that would name a hidden table or column + * are dropped. Matching goes through {@link AllowedTables} and {@link DeniedColumns}, the rules the + * query gates enforce, so the view never shows less or more than a query can reach. + */ +final class SchemaViewPermissionFilter { + + private SchemaViewPermissionFilter() { + } + + static DatabaseSchemaView apply(DatabaseSchemaView view, DatasourceUserPermissionView permission) { + if (view == null || view.schemas() == null) { + return view; + } + var allowedSchemas = AllowedTables.normalize(permission.allowedSchemas()); + var allowedTables = AllowedTables.normalize(permission.allowedTables()); + var restricted = !allowedSchemas.isEmpty() || !allowedTables.isEmpty(); + var denied = permission.deniedColumns(); + + var visible = new ArrayList(); + var visibleTableNames = new HashSet(); + for (var schema : view.schemas()) { + var normalizedSchema = AllowedTables.normalizeEntry(schema.name()); + var tables = new ArrayList(); + for (var table : nullSafe(schema.tables())) { + if (!restricted || tableAllowed(allowedSchemas, allowedTables, normalizedSchema, + table.name())) { + tables.add(table); + var bare = AllowedTables.normalizeEntry(table.name()); + if (bare != null) { + visibleTableNames.add(bare); + } + } + } + if (!tables.isEmpty() + || (normalizedSchema != null && allowedSchemas.contains(normalizedSchema)) + || !restricted) { + visible.add(new DatabaseSchemaView.Schema(schema.name(), tables)); + } + } + + var out = new ArrayList(visible.size()); + for (var schema : visible) { + var tables = new ArrayList(schema.tables().size()); + for (var table : schema.tables()) { + tables.add(narrowTable(schema.name(), table, denied, restricted, visibleTableNames)); + } + out.add(new DatabaseSchemaView.Schema(schema.name(), List.copyOf(tables))); + } + return new DatabaseSchemaView(List.copyOf(out)); + } + + private static boolean tableAllowed(List allowedSchemas, List allowedTables, + String normalizedSchema, String tableName) { + var bare = AllowedTables.normalizeEntry(tableName); + if (bare == null) { + return false; + } + if (allowedTables.contains(bare)) { + return true; + } + if (normalizedSchema == null) { + return false; + } + return AllowedTables.coveringEntry(allowedSchemas, allowedTables, + normalizedSchema + "." + bare) != null; + } + + private static DatabaseSchemaView.Table narrowTable(String schema, DatabaseSchemaView.Table table, + List denied, boolean restricted, + Set visibleTableNames) { + var columns = new ArrayList(); + for (var column : nullSafe(table.columns())) { + if (!DeniedColumns.deniesColumn(denied, schema, table.name(), column.name())) { + columns.add(column); + } + } + var foreignKeys = new ArrayList(); + for (var fk : nullSafe(table.foreignKeys())) { + if (fkVisible(schema, table.name(), fk, denied, restricted, visibleTableNames)) { + foreignKeys.add(fk); + } + } + return new DatabaseSchemaView.Table(table.name(), List.copyOf(columns), + List.copyOf(foreignKeys)); + } + + private static boolean fkVisible(String schema, String table, DatabaseSchemaView.ForeignKey fk, + List denied, boolean restricted, + Set visibleTableNames) { + if (DeniedColumns.deniesColumn(denied, schema, table, fk.fromColumn())) { + return false; + } + // The introspector reports the referenced table by bare name only, so the referenced + // column is checked against that name; a schema-qualified deny entry fails closed here. + if (DeniedColumns.deniesColumn(denied, null, fk.toTable(), fk.toColumn())) { + return false; + } + if (!restricted) { + return true; + } + var target = AllowedTables.normalizeEntry(fk.toTable()); + return target != null && visibleTableNames.contains(target); + } + + private static List nullSafe(List list) { + return list == null ? List.of() : list; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java index 7f2da0554..72ede973c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java @@ -1,13 +1,12 @@ package com.bablsoft.accessflow.workflow.internal; +import com.bablsoft.accessflow.core.api.AllowedTables; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; import com.bablsoft.accessflow.core.api.DeniedColumns; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.SqlParseResult; -import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Set; import java.util.TreeSet; @@ -88,41 +87,10 @@ static Set rejectedColumns(DatasourceUserPermissionView permission, */ static String coveringEntry(List allowedSchemas, List allowedTables, String table) { - if (allowedTables.contains(table)) { - return table; - } - int dotIdx = table.indexOf('.'); - if (dotIdx > 0) { - var schema = table.substring(0, dotIdx); - if (allowedSchemas.contains(schema)) { - return schema; - } - } - return null; + return AllowedTables.coveringEntry(allowedSchemas, allowedTables, table); } static List normalizeList(List raw) { - if (raw == null || raw.isEmpty()) { - return List.of(); - } - var out = new ArrayList(raw.size()); - for (String entry : raw) { - if (entry == null) { - continue; - } - var stripped = new StringBuilder(entry.length()); - for (int i = 0; i < entry.length(); i++) { - char c = entry.charAt(i); - if (c == '"' || c == '`' || c == '[' || c == ']') { - continue; - } - stripped.append(c); - } - var normalized = stripped.toString().trim().toLowerCase(Locale.ROOT); - if (!normalized.isEmpty()) { - out.add(normalized); - } - } - return List.copyOf(out); + return AllowedTables.normalize(raw); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/AllowedTablesTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/AllowedTablesTest.java new file mode 100644 index 000000000..fd9c816e4 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/AllowedTablesTest.java @@ -0,0 +1,45 @@ +package com.bablsoft.accessflow.core.api; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AllowedTablesTest { + + @Test + void normalizeStripsQuotesLowercasesAndDropsBlanks() { + var input = new ArrayList(); + input.add(" \"Public\".[Customer] "); + input.add("`Orders`"); + input.add(" "); + input.add(null); + + assertThat(AllowedTables.normalize(input)).containsExactly("public.customer", "orders"); + assertThat(AllowedTables.normalize(null)).isEmpty(); + assertThat(AllowedTables.normalize(List.of())).isEmpty(); + } + + @Test + void normalizeEntryReturnsNullForBlank() { + assertThat(AllowedTables.normalizeEntry(null)).isNull(); + assertThat(AllowedTables.normalizeEntry(" \"\" ")).isNull(); + assertThat(AllowedTables.normalizeEntry("[Sales]")).isEqualTo("sales"); + } + + @Test + void coveringEntryNamesTheTableOrTheSchemaThatCoversIt() { + var schemas = List.of("sales"); + var tables = List.of("public.customer", "orders"); + + assertThat(AllowedTables.coveringEntry(schemas, tables, "public.customer")) + .isEqualTo("public.customer"); + assertThat(AllowedTables.coveringEntry(schemas, tables, "orders")).isEqualTo("orders"); + assertThat(AllowedTables.coveringEntry(schemas, tables, "sales.invoice")).isEqualTo("sales"); + assertThat(AllowedTables.coveringEntry(schemas, tables, "invoice")).isNull(); + assertThat(AllowedTables.coveringEntry(schemas, tables, ".invoice")).isNull(); + assertThat(AllowedTables.coveringEntry(schemas, tables, "hr.salary")).isNull(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedColumnsTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedColumnsTest.java index 7408172d8..2bea091cd 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedColumnsTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedColumnsTest.java @@ -158,6 +158,32 @@ void columnReferenceRejectsBlankColumnAndDefaultsTables() { assertThat(ColumnReference.wildcard(Set.of("t")).isWildcard()).isTrue(); } + @Test + void deniesColumnMatchesAQualifiedEntryOnlyInItsOwnSchema() { + assertThat(DeniedColumns.deniesColumn(DENIED, "public", "customer", "national_id")).isTrue(); + assertThat(DeniedColumns.deniesColumn(DENIED, "\"PUBLIC\"", "Customer", "NATIONAL_ID")) + .isTrue(); + assertThat(DeniedColumns.deniesColumn(DENIED, "archive", "customer", "national_id")) + .isFalse(); + assertThat(DeniedColumns.deniesColumn(DENIED, "public", "customer", "email")).isFalse(); + assertThat(DeniedColumns.deniesColumn(DENIED, "public", "orders", "national_id")).isFalse(); + } + + @Test + void deniesColumnFailsClosedWhenEitherSideLacksASchema() { + assertThat(DeniedColumns.deniesColumn(List.of("customer.ssn"), "archive", "customer", "ssn")) + .isTrue(); + assertThat(DeniedColumns.deniesColumn(DENIED, null, "customer", "national_id")).isTrue(); + } + + @Test + void deniesColumnIsFalseForAnEmptyListOrBlankNames() { + assertThat(DeniedColumns.deniesColumn(List.of(), "public", "customer", "ssn")).isFalse(); + assertThat(DeniedColumns.deniesColumn(null, "public", "customer", "ssn")).isFalse(); + assertThat(DeniedColumns.deniesColumn(DENIED, "public", " ", "national_id")).isFalse(); + assertThat(DeniedColumns.deniesColumn(DENIED, "public", "customer", null)).isFalse(); + } + private static SqlParseResult parsed(QueryType type, ColumnReference... refs) { return new SqlParseResult(type, false, List.of("sql"), Set.of(), false, false, Set.of(refs), true); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImplTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImplTest.java index 66864f5e9..d36e57586 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImplTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImplTest.java @@ -75,6 +75,8 @@ class DatasourceAdminServiceImplTest { @Mock ApplicationEventPublisher eventPublisher; @Mock org.springframework.context.MessageSource messageSource; @Mock com.bablsoft.accessflow.core.api.QuotaService quotaService; + @Mock com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService + permissionLookupService; @InjectMocks DatasourceAdminServiceImpl service; private final UUID orgId = UUID.randomUUID(); @@ -1506,6 +1508,100 @@ void introspectMongoDatasourceDelegatesToEngineFromCatalog() { verify(driverCatalog, never()).resolve(any()); } + private com.bablsoft.accessflow.core.api.QueryEngine stubMongoSchema( + com.bablsoft.accessflow.core.api.DatabaseSchemaView schema) { + var entity = buildDatasource(datasourceId, orgId, "Docs"); + entity.setDbType(DbType.MONGODB); + when(datasourceRepository.findById(datasourceId)).thenReturn(Optional.of(entity)); + var engine = org.mockito.Mockito.mock(com.bablsoft.accessflow.core.api.QueryEngine.class); + org.mockito.Mockito.lenient().when(engineCatalog.isEngineManaged(DbType.MONGODB)) + .thenReturn(true); + org.mockito.Mockito.lenient().when(engineCatalog.engineFor(DbType.MONGODB)) + .thenReturn(engine); + org.mockito.Mockito.lenient().when(engine.introspectSchema(any())).thenReturn(schema); + return engine; + } + + private static com.bablsoft.accessflow.core.api.DatabaseSchemaView fourTableSchema() { + java.util.function.Function + table = name -> new com.bablsoft.accessflow.core.api.DatabaseSchemaView.Table(name, + java.util.List.of(new com.bablsoft.accessflow.core.api.DatabaseSchemaView + .Column("id", "int", false, true)), java.util.List.of()); + return new com.bablsoft.accessflow.core.api.DatabaseSchemaView(java.util.List.of( + new com.bablsoft.accessflow.core.api.DatabaseSchemaView.Schema("public", + java.util.List.of(table.apply("customer"), table.apply("service"), + table.apply("salary"), table.apply("employee"))))); + } + + private com.bablsoft.accessflow.core.api.DatasourceUserPermissionView permission( + java.util.List tables) { + return new com.bablsoft.accessflow.core.api.DatasourceUserPermissionView(UUID.randomUUID(), + userId, datasourceId, true, false, false, false, null, tables, null, null, null, + null); + } + + @Test + void introspectSchemaScopesARestrictedUserToTheirAllowedTables() { + stubMongoSchema(fourTableSchema()); + when(datasourceRepository.existsVisibleToUser(eq(datasourceId), eq(userId), any())) + .thenReturn(true); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(java.util.List.of("customer", "service")))); + + var result = service.introspectSchema(datasourceId, orgId, userId, false); + + assertThat(result.schemas()).singleElement().satisfies(schema -> + assertThat(schema.tables()) + .extracting(com.bablsoft.accessflow.core.api.DatabaseSchemaView.Table::name) + .containsExactly("customer", "service")); + } + + @Test + void introspectSchemaForAnAdminIsUnfilteredAndSkipsThePermissionLookup() { + var schema = fourTableSchema(); + stubMongoSchema(schema); + + var result = service.introspectSchema(datasourceId, orgId, userId, true); + + assertThat(result).isSameAs(schema); + verify(permissionLookupService, never()).findFor(any(), any()); + verify(datasourceRepository, never()).existsVisibleToUser(any(), any(), any()); + } + + @Test + void introspectSchemaWithoutAnEffectivePermissionIsNotFoundWithoutConnecting() { + var engine = stubMongoSchema(fourTableSchema()); + when(datasourceRepository.existsVisibleToUser(eq(datasourceId), eq(userId), any())) + .thenReturn(true); + when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.introspectSchema(datasourceId, orgId, userId, false)) + .isInstanceOf(DatasourceNotFoundException.class); + verify(engine, never()).introspectSchema(any()); + } + + @Test + void introspectSchemaForAnInvisibleDatasourceIsNotFound() { + stubMongoSchema(fourTableSchema()); + when(datasourceRepository.existsVisibleToUser(eq(datasourceId), eq(userId), any())) + .thenReturn(false); + + assertThatThrownBy(() -> service.introspectSchema(datasourceId, orgId, userId, false)) + .isInstanceOf(DatasourceNotFoundException.class); + verify(permissionLookupService, never()).findFor(any(), any()); + } + + @Test + void introspectSchemaForSystemIsNeverFiltered() { + var schema = fourTableSchema(); + stubMongoSchema(schema); + + var result = service.introspectSchemaForSystem(datasourceId, orgId); + + assertThat(result).isSameAs(schema); + verify(permissionLookupService, never()).findFor(any(), any()); + } + private DatasourceEntity buildDatasource(UUID id, UUID organizationId, String name) { var org = new OrganizationEntity(); org.setId(organizationId); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java new file mode 100644 index 000000000..42bda4d60 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java @@ -0,0 +1,142 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.DatabaseSchemaView; +import com.bablsoft.accessflow.core.api.DatabaseSchemaView.Column; +import com.bablsoft.accessflow.core.api.DatabaseSchemaView.ForeignKey; +import com.bablsoft.accessflow.core.api.DatabaseSchemaView.Schema; +import com.bablsoft.accessflow.core.api.DatabaseSchemaView.Table; +import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class SchemaViewPermissionFilterTest { + + private static Table table(String name, ForeignKey... fks) { + return new Table(name, List.of(new Column("id", "int", false, true), + new Column("name", "text", true, false), + new Column("ssn", "text", true, false)), List.of(fks)); + } + + private static DatabaseSchemaView view() { + return new DatabaseSchemaView(List.of( + new Schema("public", List.of( + table("customer", new ForeignKey("id", "employee", "id")), + table("service", new ForeignKey("id", "customer", "id")), + table("salary"), + table("employee"))), + new Schema("HR", List.of(table("payroll"))), + new Schema("empty", List.of()))); + } + + private static DatasourceUserPermissionView permission(List schemas, + List tables, + List denied) { + return new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), true, false, false, false, schemas, tables, null, denied, null, + null); + } + + private static List tableNames(DatabaseSchemaView view) { + return view.schemas().stream() + .flatMap(s -> s.tables().stream().map(t -> s.name() + "." + t.name())) + .toList(); + } + + @Test + void aTwoTableGrantSeesExactlyThoseTwoTables() { + var result = SchemaViewPermissionFilter.apply(view(), + permission(null, List.of("customer", "service"), null)); + + assertThat(tableNames(result)).containsExactly("public.customer", "public.service"); + assertThat(result.schemas()).extracting(Schema::name).containsExactly("public"); + } + + @Test + void qualifiedAndQuotedTableEntriesMatchCaseInsensitively() { + var result = SchemaViewPermissionFilter.apply(view(), + permission(null, List.of("\"PUBLIC\".\"Salary\"", "hr.PAYROLL"), null)); + + assertThat(tableNames(result)).containsExactly("public.salary", "HR.payroll"); + } + + @Test + void aSchemaGrantSeesEveryTableInThatSchemaAndKeepsAnEmptyAllowedSchema() { + var result = SchemaViewPermissionFilter.apply(view(), + permission(List.of("hr", "empty"), null, null)); + + assertThat(tableNames(result)).containsExactly("HR.payroll"); + assertThat(result.schemas()).extracting(Schema::name).containsExactly("HR", "empty"); + } + + @Test + void anEmptyAllowListIsUnrestricted() { + var result = SchemaViewPermissionFilter.apply(view(), + permission(List.of(" "), List.of(), null)); + + assertThat(tableNames(result)).hasSize(5); + assertThat(result.schemas()).hasSize(3); + assertThat(result.schemas().getFirst().tables().getFirst().foreignKeys()).hasSize(1); + } + + @Test + void foreignKeysToHiddenTablesAreDropped() { + var result = SchemaViewPermissionFilter.apply(view(), + permission(null, List.of("customer", "service"), null)); + + var tables = result.schemas().getFirst().tables(); + assertThat(tables.get(0).foreignKeys()).isEmpty(); + assertThat(tables.get(1).foreignKeys()).containsExactly(new ForeignKey("id", "customer", "id")); + } + + @Test + void deniedColumnsAndForeignKeysOverThemAreDropped() { + var withFk = new DatabaseSchemaView(List.of(new Schema("public", List.of( + new Table("customer", List.of(new Column("id", "int", false, true), + new Column("ssn", "text", true, false)), + List.of(new ForeignKey("ssn", "person", "ssn"))), + new Table("orders", List.of(new Column("customer_id", "int", false, false)), + List.of(new ForeignKey("customer_id", "customer", "id"), + new ForeignKey("customer_id", "person", "ssn"))))))); + + var result = SchemaViewPermissionFilter.apply(withFk, + permission(null, null, List.of("public.customer.ssn", "person.ssn"))); + + var tables = result.schemas().getFirst().tables(); + assertThat(tables.get(0).columns()).extracting(Column::name).containsExactly("id"); + assertThat(tables.get(0).foreignKeys()).isEmpty(); + assertThat(tables.get(1).foreignKeys()) + .containsExactly(new ForeignKey("customer_id", "customer", "id")); + } + + @Test + void aTableWithoutASchemaMatchesOnlyItsBareName() { + var schemaless = new DatabaseSchemaView(List.of(new Schema(null, List.of( + table("customer"), table("salary"))))); + + var byTable = SchemaViewPermissionFilter.apply(schemaless, + permission(List.of("public"), List.of("customer"), null)); + + assertThat(byTable.schemas().getFirst().tables()).extracting(Table::name) + .containsExactly("customer"); + } + + @Test + void nullViewsAndNullListsAreTolerated() { + assertThat(SchemaViewPermissionFilter.apply(null, permission(null, null, null))).isNull(); + var nullSchemas = new DatabaseSchemaView(null); + assertThat(SchemaViewPermissionFilter.apply(nullSchemas, permission(null, null, null))) + .isSameAs(nullSchemas); + var nullLists = new DatabaseSchemaView(List.of(new Schema("public", null), + new Schema("x", List.of(new Table("t", null, null), new Table(" ", null, null))))); + + var result = SchemaViewPermissionFilter.apply(nullLists, + permission(List.of("x"), null, null)); + + assertThat(result.schemas()).extracting(Schema::name).containsExactly("x"); + assertThat(result.schemas().getFirst().tables()).extracting(Table::name).containsExactly("t"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceConnectionTestIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceConnectionTestIntegrationTest.java index 0ce542f8e..f5f9c951e 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceConnectionTestIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceConnectionTestIntegrationTest.java @@ -181,6 +181,32 @@ void getSchemaAsAnalystWithPermissionReturnsTables() throws Exception { assertThat(result.getResponse().getContentAsString()).contains("customers"); } + @Test + void getSchemaAsAnalystIsScopedToTheirAllowListAndDeniedColumns() throws Exception { + var ds = saveDatasource(customerDb.getUsername(), customerDb.getPassword(), + customerDb.getDatabaseName()); + var perm = new DatasourceUserPermissionEntity(); + perm.setId(UUID.randomUUID()); + perm.setDatasource(ds); + perm.setUser(analyst); + perm.setCreatedBy(admin); + perm.setCanRead(true); + perm.setAllowedTables(new String[]{"customers"}); + perm.setDeniedColumns(new String[]{"public.customers.email"}); + permissionRepository.save(perm); + + var analystBody = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/schema") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) + .exchange().getResponse().getContentAsString(); + var adminBody = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/schema") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange().getResponse().getContentAsString(); + + assertThat(analystBody).contains("customers").doesNotContain("orders") + .doesNotContain("email"); + assertThat(adminBody).contains("customers").contains("orders").contains("email"); + } + private UserEntity saveUser(String email, UserRoleType role) { var user = new UserEntity(); user.setId(UUID.randomUUID()); diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 71b21be6f..12f331290 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -176,7 +176,7 @@ The list is rendered by the `LanguageSwitcher` component in `mode="public"`; sel | `DELETE` | `/datasources/{id}` | ADMIN | Soft-delete datasource | | `POST` | `/datasources/{id}/test` | ADMIN | Test connection to customer database | | `POST` | `/datasources/{id}/test-replica` | ADMIN | Test a read-replica connection using live form values | -| `GET` | `/datasources/{id}/schema` | Any (with access) | Introspect tables and columns from customer DB | +| `GET` | `/datasources/{id}/schema` | Any (with access) | Introspect tables and columns from customer DB — non-admins see only what their grant allows (#936) | | `GET` | `/datasources/{id}/permissions` | ADMIN | List all user permissions for a datasource | | `POST` | `/datasources/{id}/permissions` | ADMIN | Grant a user permission on a datasource | | `DELETE` | `/datasources/{id}/permissions/{permId}` | ADMIN | Revoke a permission | @@ -535,7 +535,7 @@ Opens a transient JDBC connection to a candidate read-replica using the values s ### GET /datasources/{id}/schema — Response -Introspects tables and columns from the customer database via JDBC `DatabaseMetaData`. System schemas (`pg_catalog`, `information_schema`, `pg_toast`, `mysql`, `performance_schema`, `sys`) are filtered out. ADMINs may introspect any datasource in their organization; non-ADMINs require a permission row. +Introspects tables and columns from the customer database via JDBC `DatabaseMetaData`. System schemas (`pg_catalog`, `information_schema`, `pg_toast`, `mysql`, `performance_schema`, `sys`) are filtered out. ADMINs may introspect any datasource in their organization and see every table. Non-ADMINs require an effective permission (direct or group grant; none → `404 DATASOURCE_NOT_FOUND`), and the response is scoped to it (#936): only tables their `allowed_schemas` / `allowed_tables` cover are returned (a table is shown when its bare or `schema.table` name is listed, or its schema is — the same matcher the query gate uses; both lists empty means no restriction), schemas left with no visible table are dropped unless the schema itself is allow-listed, columns on the grant's `denied_columns` are omitted, and a foreign key is omitted when it starts from a denied column, points at a denied column, or references a table the caller cannot see. The same scoping applies wherever this user-facing introspection feeds another surface — editor autocomplete, the table preview, the AI analyze-preview and text-to-SQL schema context, and the MCP `get_datasource_schema` / `validate_sql` tools. System paths (async AI analysis, discovery scans, schema drift, query snapshots) introspect unfiltered, and the JIT request form keeps its own name-only, unfiltered endpoint (`GET /access-requests/datasources/{id}/schema`). ```json { diff --git a/docs/05-backend.md b/docs/05-backend.md index d09397cae..4da1e97b9 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -1021,6 +1021,8 @@ simulation must not become a side channel for them. Samples carry the query id, The result is returned via `DatabaseSchemaView` (immutable nested records: `Schema → Table → Column` + `ForeignKey`). The web layer maps to `DatabaseSchemaResponse` for the `GET /api/v1/datasources/{id}/schema` endpoint; the AI module consumes the same view via `SystemPromptRenderer.describeSchema(...)`. +**Scoped to the caller (#936).** For a non-admin, `introspectSchema` resolves the effective permission (`DatasourceUserPermissionLookupService.findFor`; none → `DatasourceNotFoundException`, before any connection is opened) and passes the view through the pure `core.internal.SchemaViewPermissionFilter`: tables outside `allowed_schemas`/`allowed_tables` are dropped (visible when the bare or `schema.table` name is listed or the schema is — `core.api.AllowedTables`, the same `normalize`/`coveringEntry` the query gate's `DatasourcePermissionChecker` delegates to, so the view can never disagree with enforcement), schemas left empty are dropped unless allow-listed, columns matched by `DeniedColumns.deniesColumn` are removed, and foreign keys from or to a denied column, or to a table the caller cannot see, are removed (the introspector reports `toTable` by bare name, so a schema-qualified deny entry on the referenced side fails closed). Admins get the unfiltered view. `introspectSchemaForSystem` — async AI analysis, discovery, drift, snapshots, the JIT request form — is never filtered. Every user-path caller (editor, table preview, AI analyze-preview, text-to-SQL, MCP `get_datasource_schema` / `validate_sql`) inherits the scoping: a forbidden table looks absent, which is the point. + ### Sample data path (AF-443) `proxy.api.SampleDataService` returns a bounded, fully-governed sample of a single table's rows for the schema-explorer UI — an **ad-hoc read that bypasses review but not governance**. It does *not* create a `query_request`; it resolves the caller's directives and runs through the executor exactly like `DefaultQueryLifecycleService.doExecute`: diff --git a/docs/07-security.md b/docs/07-security.md index c76dbc57f..8b3f30424 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -760,6 +760,13 @@ Are restricted_columns set? PROCEED to review plan ``` +**Discovery follows the same rules (#936).** `GET /datasources/{id}/schema` and every surface built on +it (editor autocomplete, table preview, AI preview / text-to-SQL context, MCP `get_datasource_schema`) +show a non-admin only the tables their allow-list covers, without their denied columns or foreign keys +that would name either — through the same `core.api.AllowedTables` / `DeniedColumns` matchers the gate +above uses. Admins and system-actor introspection are unfiltered; the JIT request form keeps its +name-only unfiltered listing, because requesting access to something you cannot yet see is its purpose. + ### Automatic query suggestion visibility (#776) The editor's suggestion rail offers **other analysts' approved SQL**. That makes visibility the diff --git a/docs/13-mcp.md b/docs/13-mcp.md index 16d2bd153..e894c643b 100644 --- a/docs/13-mcp.md +++ b/docs/13-mcp.md @@ -143,7 +143,7 @@ names both `application/json` and `text/event-stream`; real MCP clients send bot | Tool | Args | Returns | |------|------|---------| | `list_datasources` | `page?`, `size?` | Paginated list of datasources the caller can query (`id`, `name`, `db_type`, `host`, `database_name`, `active`, `require_review_reads/writes`). Admins see all org datasources. | -| `get_datasource_schema` | `datasourceId` | `{ schemas: [{ name, tables: [{ name, columns: [{ name, type, nullable, primaryKey }] }] }] }`. Use this to discover what tables and columns exist before writing SQL. | +| `get_datasource_schema` | `datasourceId` | `{ schemas: [{ name, tables: [{ name, columns: [{ name, type, nullable, primaryKey }] }] }] }`. Use this to discover what tables and columns exist before writing SQL. Non-admin callers see only the tables their grant's allow-list covers, without denied columns (#936). | | `list_my_queries` | `status?`, `datasourceId?`, `queryType?`, `page?`, `size?` | Caller's own queries (newest first). `status` is forced to the caller's history regardless of args — admins still see all submitters' queries via the REST endpoint, not this tool. | | `get_query_status` | `queryId` | Full detail: status, AI risk, review decisions, execution outcome. Submitter-or-admin only. | | `get_query_result` | `queryId` | Rows + columns (as JSON strings) for an `EXECUTED` `SELECT`. Returns `invalid_state` if the query is the wrong type or not executed yet. | diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index 3fdddd628..be50db320 100644 --- a/help-corpus/corpus.jsonl +++ b/help-corpus/corpus.jsonl @@ -203,7 +203,7 @@ {"id":"ab70ab607bfeefd9","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":2,"tokens":406,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 3 of 9)\n\naccepts a secret reference — vault:/#, aws:[#jsonField], or azure: — stored as-is and resolved through the store at connection time; the form shows the syntax hints for whichever providers are enabled.\n\n- Connection test. AccessFlow opens a real JDBC connection, runs a heartbeat query, and surfaces any SSL / authentication errors before you save.\n\n- Configuration. Pick the Review plan that gates this datasource, toggle Require review on reads / writes, and (optionally) enable AI analysis and/or text-to-query + pick an AI configuration. The AI configuration is shared by both features, so it is required whenever either toggle is on. With text-to-query on, users can draft a query from a natural-language prompt in the editor — in the engine's native query language (SQL or a NoSQL query) — and the draft still flows through the normal review pipeline. Pool size, max rows, and statement timeout default sensibly but can be tightened per datasource. An optional Environment (Development, Test, Staging or Production) picks which SQL review ruleset applies to queries on this datasource — leave it unset to use the organization default."} {"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":604,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 9)\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.\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions."} +{"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":751,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 9)\n\nUsers only see what they are granted. The same lists decide what a user can discover. The Schema tab, editor autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, without their denied columns, so the names of other tables never reach them. Administrators still see every table. The just-in-time access request form keeps listing every schema and table name, because asking for access to a table you cannot see yet is what it is for.\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.\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions."} {"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":649,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 9)\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":"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."} diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json index 787716e9c..de9b09c26 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "8754c1134523", - "generatedAt": "2026-09-24T07:45:40.106Z", - "sourceCommit": "12cdf69b6b1d287d873800163956e8ecb57b4540", + "corpusVersion": "ba3926991e47", + "generatedAt": "2026-09-24T09:08:21.276Z", + "sourceCommit": "bb996c389c0b4ea5e7d78d98bd8d126f6822df92", "chunkCount": 586, - "sha256": "8754c11345235a237df17da7a92d4c2a678b4ce8a27e39a7bdead9eb53d14f42", + "sha256": "ba3926991e47d29e5ffb357bc885434bb53eae11b4feecde138e57a3c79f1b3b", "quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66", "sources": [ { @@ -205,7 +205,7 @@ "url": "https://accessflow.io/docs/configuration/datasources/", "section": "Reference", "chunks": 15, - "sha256": "7a9107c02bf639b783a0d3b0ac81569ca31f39a73a6ebb02cb9d85be76a62cc5" + "sha256": "ad6583983ea0f4060967272b5181e6fadd884a65546e66caf65225306941988c" }, { "path": "website/docs/configuration/notifications/index.html", diff --git a/website/docs/configuration/datasources/index.html b/website/docs/configuration/datasources/index.html index 87ae405b1..0c11f061e 100644 --- a/website/docs/configuration/datasources/index.html +++ b/website/docs/configuration/datasources/index.html @@ -360,6 +360,9 @@

What is a datasource in AccessFlow?

  • 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.
  • Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.
  • +

    + Users only see what they are granted. The same lists decide what a user can discover. The Schema tab, editor autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, without their denied columns, so the names of other tables never reach them. Administrators still see every table. The just-in-time access request form keeps listing every schema and table name, because asking for access to a table you cannot see yet is what it is for. +

    Schema explorer & ER diagram. Each datasource also carries Schema and ER diagram tabs alongside Configuration / From 4bfc5542068a67c5a6904c68338c09fe82d9ecab Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 24 Sep 2026 13:22:48 +0400 Subject: [PATCH 2/4] fix(AF-936): fail closed on ambiguous names, FKs; tighten docs --- .../internal/SchemaViewPermissionFilter.java | 64 +++++++++++++++---- .../SchemaViewPermissionFilterTest.java | 38 +++++++++++ ...tasourceConnectionTestIntegrationTest.java | 12 ++-- docs/04-api-spec.md | 2 +- docs/05-backend.md | 2 +- docs/07-security.md | 8 ++- docs/13-mcp.md | 2 +- help-corpus/corpus.jsonl | 4 +- help-corpus/manifest.json | 10 +-- .../docs/configuration/datasources/index.html | 2 +- 10 files changed, 113 insertions(+), 31 deletions(-) diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java index c707b930f..ff4fbc8cb 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java @@ -14,7 +14,10 @@ * Narrows an introspected schema to what a restricted caller may query (#936): tables outside the * allow-list, columns on the deny list, and foreign keys that would name a hidden table or column * are dropped. Matching goes through {@link AllowedTables} and {@link DeniedColumns}, the rules the - * query gates enforce, so the view never shows less or more than a query can reach. + * query gates enforce. Where the view cannot tell what the gate would allow it fails closed: a bare + * {@code allowed_tables} entry names whatever table the database resolves the unqualified name to, + * so it only shows a table whose name is unique in the view, and a foreign key whose bare target name + * also belongs to a hidden table is dropped. */ final class SchemaViewPermissionFilter { @@ -30,19 +33,23 @@ static DatabaseSchemaView apply(DatabaseSchemaView view, DatasourceUserPermissio var restricted = !allowedSchemas.isEmpty() || !allowedTables.isEmpty(); var denied = permission.deniedColumns(); + var ambiguousNames = ambiguousTableNames(view); var visible = new ArrayList(); var visibleTableNames = new HashSet(); + var hiddenTableNames = new HashSet(); for (var schema : view.schemas()) { var normalizedSchema = AllowedTables.normalizeEntry(schema.name()); var tables = new ArrayList(); for (var table : nullSafe(schema.tables())) { + var bare = AllowedTables.normalizeEntry(table.name()); if (!restricted || tableAllowed(allowedSchemas, allowedTables, normalizedSchema, - table.name())) { + bare, ambiguousNames)) { tables.add(table); - var bare = AllowedTables.normalizeEntry(table.name()); if (bare != null) { visibleTableNames.add(bare); } + } else if (bare != null) { + hiddenTableNames.add(bare); } } if (!tables.isEmpty() @@ -56,7 +63,8 @@ static DatabaseSchemaView apply(DatabaseSchemaView view, DatasourceUserPermissio for (var schema : visible) { var tables = new ArrayList(schema.tables().size()); for (var table : schema.tables()) { - tables.add(narrowTable(schema.name(), table, denied, restricted, visibleTableNames)); + tables.add(narrowTable(schema.name(), table, denied, restricted, visibleTableNames, + hiddenTableNames)); } out.add(new DatabaseSchemaView.Schema(schema.name(), List.copyOf(tables))); } @@ -64,24 +72,50 @@ static DatabaseSchemaView apply(DatabaseSchemaView view, DatasourceUserPermissio } private static boolean tableAllowed(List allowedSchemas, List allowedTables, - String normalizedSchema, String tableName) { - var bare = AllowedTables.normalizeEntry(tableName); + String normalizedSchema, String bare, + Set ambiguousNames) { if (bare == null) { return false; } - if (allowedTables.contains(bare)) { + if (allowedTables.contains(bare) && !ambiguousNames.contains(bare)) { return true; } if (normalizedSchema == null) { return false; } - return AllowedTables.coveringEntry(allowedSchemas, allowedTables, - normalizedSchema + "." + bare) != null; + var qualified = normalizedSchema + "." + bare; + if (AllowedTables.coveringEntry(allowedSchemas, allowedTables, qualified) != null) { + return true; + } + // A catalog-qualified entry (project.dataset.table, db.schema.table) covers the table the + // view reports under its trailing schema.table. + var suffix = "." + qualified; + for (String entry : allowedTables) { + if (entry.endsWith(suffix)) { + return true; + } + } + return false; + } + + private static Set ambiguousTableNames(DatabaseSchemaView view) { + var seen = new HashSet(); + var ambiguous = new HashSet(); + for (var schema : view.schemas()) { + for (var table : nullSafe(schema.tables())) { + var bare = AllowedTables.normalizeEntry(table.name()); + if (bare != null && !seen.add(bare)) { + ambiguous.add(bare); + } + } + } + return ambiguous; } private static DatabaseSchemaView.Table narrowTable(String schema, DatabaseSchemaView.Table table, List denied, boolean restricted, - Set visibleTableNames) { + Set visibleTableNames, + Set hiddenTableNames) { var columns = new ArrayList(); for (var column : nullSafe(table.columns())) { if (!DeniedColumns.deniesColumn(denied, schema, table.name(), column.name())) { @@ -90,7 +124,8 @@ private static DatabaseSchemaView.Table narrowTable(String schema, DatabaseSchem } var foreignKeys = new ArrayList(); for (var fk : nullSafe(table.foreignKeys())) { - if (fkVisible(schema, table.name(), fk, denied, restricted, visibleTableNames)) { + if (fkVisible(schema, table.name(), fk, denied, restricted, visibleTableNames, + hiddenTableNames)) { foreignKeys.add(fk); } } @@ -100,7 +135,8 @@ private static DatabaseSchemaView.Table narrowTable(String schema, DatabaseSchem private static boolean fkVisible(String schema, String table, DatabaseSchemaView.ForeignKey fk, List denied, boolean restricted, - Set visibleTableNames) { + Set visibleTableNames, + Set hiddenTableNames) { if (DeniedColumns.deniesColumn(denied, schema, table, fk.fromColumn())) { return false; } @@ -112,8 +148,10 @@ private static boolean fkVisible(String schema, String table, DatabaseSchemaView if (!restricted) { return true; } + // toTable carries no schema, so a name shared with a hidden table could point at it. var target = AllowedTables.normalizeEntry(fk.toTable()); - return target != null && visibleTableNames.contains(target); + return target != null && visibleTableNames.contains(target) + && !hiddenTableNames.contains(target); } private static List nullSafe(List list) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java index 42bda4d60..5810c112d 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java @@ -124,6 +124,44 @@ void aTableWithoutASchemaMatchesOnlyItsBareName() { .containsExactly("customer"); } + @Test + void aBareEntryDoesNotRevealASameNamedTableInAnotherSchema() { + var shared = new DatabaseSchemaView(List.of( + new Schema("public", List.of(table("customer"), table("orders"))), + new Schema("hr", List.of(table("customer"))))); + + var bare = SchemaViewPermissionFilter.apply(shared, + permission(null, List.of("customer", "orders"), null)); + var qualified = SchemaViewPermissionFilter.apply(shared, + permission(null, List.of("public.customer"), null)); + + assertThat(tableNames(bare)).containsExactly("public.orders"); + assertThat(tableNames(qualified)).containsExactly("public.customer"); + } + + @Test + void aCatalogQualifiedEntryCoversTheTrailingSchemaAndTable() { + var result = SchemaViewPermissionFilter.apply(view(), + permission(null, List.of("proj.public.salary", "other.hr.payroll.x"), null)); + + assertThat(tableNames(result)).containsExactly("public.salary"); + } + + @Test + void aForeignKeyWhoseTargetNameAlsoBelongsToAHiddenTableIsDropped() { + var shared = new DatabaseSchemaView(List.of( + new Schema("public", List.of( + table("orders", new ForeignKey("id", "customer", "id")), + table("customer"))), + new Schema("hr", List.of(table("customer"))))); + + var result = SchemaViewPermissionFilter.apply(shared, + permission(null, List.of("public.orders", "public.customer"), null)); + + assertThat(tableNames(result)).containsExactly("public.orders", "public.customer"); + assertThat(result.schemas().getFirst().tables().getFirst().foreignKeys()).isEmpty(); + } + @Test void nullViewsAndNullListsAreTolerated() { assertThat(SchemaViewPermissionFilter.apply(null, permission(null, null, null))).isNull(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceConnectionTestIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceConnectionTestIntegrationTest.java index f5f9c951e..beab5f2f1 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceConnectionTestIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceConnectionTestIntegrationTest.java @@ -195,12 +195,16 @@ void getSchemaAsAnalystIsScopedToTheirAllowListAndDeniedColumns() throws Excepti perm.setDeniedColumns(new String[]{"public.customers.email"}); permissionRepository.save(perm); - var analystBody = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/schema") + var analystResult = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/schema") .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) - .exchange().getResponse().getContentAsString(); - var adminBody = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/schema") + .exchange(); + var adminResult = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/schema") .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) - .exchange().getResponse().getContentAsString(); + .exchange(); + assertThat(analystResult).hasStatus(200); + assertThat(adminResult).hasStatus(200); + var analystBody = analystResult.getResponse().getContentAsString(); + var adminBody = adminResult.getResponse().getContentAsString(); assertThat(analystBody).contains("customers").doesNotContain("orders") .doesNotContain("email"); diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 12f331290..89a8c5a32 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -535,7 +535,7 @@ Opens a transient JDBC connection to a candidate read-replica using the values s ### GET /datasources/{id}/schema — Response -Introspects tables and columns from the customer database via JDBC `DatabaseMetaData`. System schemas (`pg_catalog`, `information_schema`, `pg_toast`, `mysql`, `performance_schema`, `sys`) are filtered out. ADMINs may introspect any datasource in their organization and see every table. Non-ADMINs require an effective permission (direct or group grant; none → `404 DATASOURCE_NOT_FOUND`), and the response is scoped to it (#936): only tables their `allowed_schemas` / `allowed_tables` cover are returned (a table is shown when its bare or `schema.table` name is listed, or its schema is — the same matcher the query gate uses; both lists empty means no restriction), schemas left with no visible table are dropped unless the schema itself is allow-listed, columns on the grant's `denied_columns` are omitted, and a foreign key is omitted when it starts from a denied column, points at a denied column, or references a table the caller cannot see. The same scoping applies wherever this user-facing introspection feeds another surface — editor autocomplete, the table preview, the AI analyze-preview and text-to-SQL schema context, and the MCP `get_datasource_schema` / `validate_sql` tools. System paths (async AI analysis, discovery scans, schema drift, query snapshots) introspect unfiltered, and the JIT request form keeps its own name-only, unfiltered endpoint (`GET /access-requests/datasources/{id}/schema`). +Introspects tables and columns from the customer database via JDBC `DatabaseMetaData`. System schemas (`pg_catalog`, `information_schema`, `pg_toast`, `mysql`, `performance_schema`, `sys`) are filtered out. ADMINs may introspect any datasource in their organization and see every table. Non-ADMINs require an effective permission (direct or group grant; none → `404 DATASOURCE_NOT_FOUND`), and the response is scoped to it (#936): only tables their `allowed_schemas` / `allowed_tables` cover are returned (a table is shown when its `schema.table` name — or a catalog-qualified `catalog.schema.table` ending in it — is listed, when its schema is listed, or when its bare name is listed **and no other schema has a table of that name**; the qualified rules are the query gate's own matcher, and the ambiguous bare case fails closed because the view cannot know which table the database resolves an unqualified name to — qualify the entry. Both lists empty means no restriction), schemas left with no visible table are dropped unless the schema itself is allow-listed, columns on the grant's `denied_columns` are omitted, and a foreign key is omitted when it starts from a denied column, points at a denied column, or references a table the caller cannot see (foreign-key targets are reported by bare name, so one whose name is shared with a hidden table is omitted too). The same scoping applies wherever this user-facing introspection feeds another surface — editor autocomplete, the table preview, the AI analyze-preview and text-to-SQL schema context, and the MCP `get_datasource_schema` / `validate_sql` tools. System paths (async AI analysis at submission, discovery scans, schema drift and promotion snapshots, query snapshots, query-replay compatibility) introspect unfiltered, and the JIT request form keeps its own name-only, unfiltered endpoint (`GET /access-requests/datasources/{id}/schema`). The matcher follows relational qualification; for engine plugins with their own naming (Couchbase `bucket.scope.collection` grants, Elasticsearch index patterns) the view can hide objects the engine's gate allows — admins see everything, and the query gate is unchanged. ```json { diff --git a/docs/05-backend.md b/docs/05-backend.md index 4da1e97b9..bff6afeca 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -1021,7 +1021,7 @@ simulation must not become a side channel for them. Samples carry the query id, The result is returned via `DatabaseSchemaView` (immutable nested records: `Schema → Table → Column` + `ForeignKey`). The web layer maps to `DatabaseSchemaResponse` for the `GET /api/v1/datasources/{id}/schema` endpoint; the AI module consumes the same view via `SystemPromptRenderer.describeSchema(...)`. -**Scoped to the caller (#936).** For a non-admin, `introspectSchema` resolves the effective permission (`DatasourceUserPermissionLookupService.findFor`; none → `DatasourceNotFoundException`, before any connection is opened) and passes the view through the pure `core.internal.SchemaViewPermissionFilter`: tables outside `allowed_schemas`/`allowed_tables` are dropped (visible when the bare or `schema.table` name is listed or the schema is — `core.api.AllowedTables`, the same `normalize`/`coveringEntry` the query gate's `DatasourcePermissionChecker` delegates to, so the view can never disagree with enforcement), schemas left empty are dropped unless allow-listed, columns matched by `DeniedColumns.deniesColumn` are removed, and foreign keys from or to a denied column, or to a table the caller cannot see, are removed (the introspector reports `toTable` by bare name, so a schema-qualified deny entry on the referenced side fails closed). Admins get the unfiltered view. `introspectSchemaForSystem` — async AI analysis, discovery, drift, snapshots, the JIT request form — is never filtered. Every user-path caller (editor, table preview, AI analyze-preview, text-to-SQL, MCP `get_datasource_schema` / `validate_sql`) inherits the scoping: a forbidden table looks absent, which is the point. +**Scoped to the caller (#936).** For a non-admin, `introspectSchema` resolves the effective permission (`DatasourceUserPermissionLookupService.findFor`; none → `DatasourceNotFoundException`, before any connection is opened) and passes the view through the pure `core.internal.SchemaViewPermissionFilter`: tables outside `allowed_schemas`/`allowed_tables` are dropped. A table is visible when `core.api.AllowedTables.coveringEntry` — the matcher the query gate's `DatasourcePermissionChecker` delegates to — covers its `schema.table`, when an entry ends in `.schema.table` (catalog-qualified BigQuery / SQL Server grants), or when its bare name is listed and no other schema in the view has a table of that name. The last rule fails closed: a bare entry grants whatever the database resolves the unqualified name to, which the view cannot know, so an ambiguous name is shown nowhere until the admin qualifies the entry. Schemas left empty are dropped unless allow-listed, columns matched by `DeniedColumns.deniesColumn` are removed, and foreign keys from or to a denied column, to a table the caller cannot see, or to a bare name shared with a hidden table are removed (the introspector reports `toTable` by bare name, so a schema-qualified deny entry on the referenced side fails closed too). The matching is relational; engine plugins that qualify differently (Couchbase grants on `bucket.scope.collection` while its view names schemas by scope; Elasticsearch index patterns) can see fewer objects than their gate allows — a degradation, never a bypass, since query enforcement is unchanged. Admins get the unfiltered view. `introspectSchemaForSystem` — async AI analysis at submission, discovery, drift, promotion and query snapshots, query-replay compatibility, the JIT request form — is never filtered. Known residual: the submission-time AI analysis prompt is built from the unfiltered view, so its free-text issues shown to the submitter can in principle name a table outside their grant. Every user-path caller (editor, table preview, AI analyze-preview, text-to-SQL, MCP `get_datasource_schema` / `validate_sql`) inherits the scoping: a forbidden table looks absent, which is the point. ### Sample data path (AF-443) diff --git a/docs/07-security.md b/docs/07-security.md index 8b3f30424..bdfc64132 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -762,9 +762,11 @@ Are restricted_columns set? **Discovery follows the same rules (#936).** `GET /datasources/{id}/schema` and every surface built on it (editor autocomplete, table preview, AI preview / text-to-SQL context, MCP `get_datasource_schema`) -show a non-admin only the tables their allow-list covers, without their denied columns or foreign keys -that would name either — through the same `core.api.AllowedTables` / `DeniedColumns` matchers the gate -above uses. Admins and system-actor introspection are unfiltered; the JIT request form keeps its +show a non-admin only the tables their allow-list covers, without their denied columns, and without +foreign keys that start from or point at a denied column or at a hidden table — through the same +`core.api.AllowedTables` / `DeniedColumns` matchers the gate above uses. Where the view cannot tell +what the gate would allow it fails closed: a bare `allowed_tables` entry shows a table only when no +other schema has one of the same name. Admins and system-actor introspection are unfiltered; the JIT request form keeps its name-only unfiltered listing, because requesting access to something you cannot yet see is its purpose. ### Automatic query suggestion visibility (#776) diff --git a/docs/13-mcp.md b/docs/13-mcp.md index e894c643b..c4dea3314 100644 --- a/docs/13-mcp.md +++ b/docs/13-mcp.md @@ -147,7 +147,7 @@ names both `application/json` and `text/event-stream`; real MCP clients send bot | `list_my_queries` | `status?`, `datasourceId?`, `queryType?`, `page?`, `size?` | Caller's own queries (newest first). `status` is forced to the caller's history regardless of args — admins still see all submitters' queries via the REST endpoint, not this tool. | | `get_query_status` | `queryId` | Full detail: status, AI risk, review decisions, execution outcome. Submitter-or-admin only. | | `get_query_result` | `queryId` | Rows + columns (as JSON strings) for an `EXECUTED` `SELECT`. Returns `invalid_state` if the query is the wrong type or not executed yet. | -| `validate_sql` | `datasourceId`, `sql` | Parse-only check against the datasource's engine — **no AI cost, no execution**. Returns `{ valid, queryType, referencedTables, hasWhereClause, hasLimitClause, unknownTables, schemaChecked, parseError }`. Parse errors come back as `valid: false` + `parseError` (data, not an MCP error). `unknownTables` are referenced tables absent from the schema you can see; `schemaChecked` is `false` when the database was unreachable (mismatch skipped). Use it to sanity-check a draft before `submit_query`. | +| `validate_sql` | `datasourceId`, `sql` | Parse-only check against the datasource's engine — **no AI cost, no execution**. Returns `{ valid, queryType, referencedTables, hasWhereClause, hasLimitClause, unknownTables, schemaChecked, parseError }`. Parse errors come back as `valid: false` + `parseError` (data, not an MCP error). `unknownTables` are referenced tables absent from the schema you can see — tables outside your grant count as absent (#936); `schemaChecked` is `false` when the database was unreachable (mismatch skipped). Use it to sanity-check a draft before `submit_query`. | | `get_column_samples` | `datasourceId`, `schema?`, `table`, `limit?` | Bounded sample rows (default 50, max 200) with the **same row-level security and column masking** as a governed read — masked columns carry the masked value, never the raw one (`restricted` flags them). Requires read access to the table; `not_found` for an unknown or forbidden table. | | `get_audit_log` | `action?`, `resourceType?`, `from?`, `to?`, `page?`, `size?` | The **caller's own** audit entries (newest first), scoped to the caller + their organisation — never another user's activity. `from`/`to` are ISO-8601 instants; `resourceType` accepts the enum name (`QUERY_REQUEST`) or db value (`query_request`); `action` is an `AuditAction`. | diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index be50db320..cb2ba547e 100644 --- a/help-corpus/corpus.jsonl +++ b/help-corpus/corpus.jsonl @@ -203,8 +203,8 @@ {"id":"ab70ab607bfeefd9","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":2,"tokens":406,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 3 of 9)\n\naccepts a secret reference — vault:/#, aws:[#jsonField], or azure: — stored as-is and resolved through the store at connection time; the form shows the syntax hints for whichever providers are enabled.\n\n- Connection test. AccessFlow opens a real JDBC connection, runs a heartbeat query, and surfaces any SSL / authentication errors before you save.\n\n- Configuration. Pick the Review plan that gates this datasource, toggle Require review on reads / writes, and (optionally) enable AI analysis and/or text-to-query + pick an AI configuration. The AI configuration is shared by both features, so it is required whenever either toggle is on. With text-to-query on, users can draft a query from a natural-language prompt in the editor — in the engine's native query language (SQL or a NoSQL query) — and the draft still flows through the normal review pipeline. Pool size, max rows, and statement timeout default sensibly but can be tightened per datasource. An optional Environment (Development, Test, Staging or Production) picks which SQL review ruleset applies to queries on this datasource — leave it unset to use the organization default."} {"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":751,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 9)\n\nUsers only see what they are granted. The same lists decide what a user can discover. The Schema tab, editor autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, without their denied columns, so the names of other tables never reach them. Administrators still see every table. The just-in-time access request form keeps listing every schema and table name, because asking for access to a table you cannot see yet is what it is for.\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.\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions."} -{"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":649,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 9)\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":"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":"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 de9b09c26..e00c1822a 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "ba3926991e47", - "generatedAt": "2026-09-24T09:08:21.276Z", - "sourceCommit": "bb996c389c0b4ea5e7d78d98bd8d126f6822df92", + "corpusVersion": "2561f6312374", + "generatedAt": "2026-09-24T09:22:48.045Z", + "sourceCommit": "9518af83030350beb8f73fdac7e8731893d5b958", "chunkCount": 586, - "sha256": "ba3926991e47d29e5ffb357bc885434bb53eae11b4feecde138e57a3c79f1b3b", + "sha256": "2561f63123747e9089d17bd0592b6d09b15856388e9ed7262405df00e882d9f7", "quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66", "sources": [ { @@ -205,7 +205,7 @@ "url": "https://accessflow.io/docs/configuration/datasources/", "section": "Reference", "chunks": 15, - "sha256": "ad6583983ea0f4060967272b5181e6fadd884a65546e66caf65225306941988c" + "sha256": "23feb73f450f20f6a0eb8a7ee8b963e5c9e33c8fd61e93fb26b12ed8a9281c6e" }, { "path": "website/docs/configuration/notifications/index.html", diff --git a/website/docs/configuration/datasources/index.html b/website/docs/configuration/datasources/index.html index 0c11f061e..342041984 100644 --- a/website/docs/configuration/datasources/index.html +++ b/website/docs/configuration/datasources/index.html @@ -361,7 +361,7 @@

    What is a datasource in AccessFlow?

  • Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.
  • - Users only see what they are granted. The same lists decide what a user can discover. The Schema tab, editor autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, without their denied columns, so the names of other tables never reach them. Administrators still see every table. The just-in-time access request form keeps listing every schema and table name, because asking for access to a table you cannot see yet is what it is for. + Users 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.

    Schema explorer & ER diagram. Each datasource also carries From bc64d7701833a547ac98de79f64180a52a79b244 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 24 Sep 2026 13:32:50 +0400 Subject: [PATCH 3/4] fix(AF-1089): match table preview allow-list to the query gate DefaultSampleDataService now matches its target through core.api.AllowedTables, so a bare allowed_tables entry no longer admits a schema-qualified preview the query gate rejects; a bare entry covers a qualified target only when the name is unique in the view (#936 rule). DefaultQueryDryRunService drops its duplicate normalizer too. Refs #1089 --- .../internal/DefaultQueryDryRunService.java | 41 +------- .../internal/DefaultSampleDataService.java | 78 +++++++-------- .../DefaultSampleDataServiceTest.java | 97 +++++++++++++++++++ docs/05-backend.md | 4 +- 4 files changed, 138 insertions(+), 82 deletions(-) diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunService.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunService.java index 3233fb1ca..0545b068a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunService.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.proxy.internal; +import com.bablsoft.accessflow.core.api.AllowedTables; import com.bablsoft.accessflow.core.api.DatasourceAdminService; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; @@ -21,9 +22,7 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Set; import java.util.TreeSet; import java.util.UUID; @@ -106,8 +105,8 @@ private void verifyPermission(UUID userId, UUID datasourceId, SqlParseResult par private void verifyAllowedTables(DatasourceUserPermissionView permission, UUID datasourceId, Set referencedTables) { - var allowedSchemas = normalizeList(permission.allowedSchemas()); - var allowedTables = normalizeList(permission.allowedTables()); + var allowedSchemas = AllowedTables.normalize(permission.allowedSchemas()); + var allowedTables = AllowedTables.normalize(permission.allowedTables()); if (allowedSchemas.isEmpty() && allowedTables.isEmpty()) { return; } @@ -116,14 +115,9 @@ private void verifyAllowedTables(DatasourceUserPermissionView permission, UUID d } var rejected = new TreeSet(); for (String table : referencedTables) { - if (allowedTables.contains(table)) { - continue; + if (AllowedTables.coveringEntry(allowedSchemas, allowedTables, table) == null) { + rejected.add(table); } - int dotIdx = table.indexOf('.'); - if (dotIdx > 0 && allowedSchemas.contains(table.substring(0, dotIdx))) { - continue; - } - rejected.add(table); } if (!rejected.isEmpty()) { log.warn("Dry-run allow-list rejection on datasource {} for user {}: tables {}", @@ -142,31 +136,6 @@ private static boolean hasCapability(DatasourceUserPermissionView permission, Qu }; } - private static List normalizeList(List raw) { - if (raw == null || raw.isEmpty()) { - return List.of(); - } - var out = new ArrayList(raw.size()); - for (String entry : raw) { - if (entry == null) { - continue; - } - var stripped = new StringBuilder(entry.length()); - for (int i = 0; i < entry.length(); i++) { - char c = entry.charAt(i); - if (c == '"' || c == '`' || c == '[' || c == ']') { - continue; - } - stripped.append(c); - } - var normalized = stripped.toString().trim().toLowerCase(Locale.ROOT); - if (!normalized.isEmpty()) { - out.add(normalized); - } - } - return List.copyOf(out); - } - private String msg(String key, Object[] args) { return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java index 56fb26c57..add42233e 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.proxy.internal; +import com.bablsoft.accessflow.core.api.AllowedTables; import com.bablsoft.accessflow.core.api.ColumnMaskDirective; import com.bablsoft.accessflow.core.api.DatabaseSchemaView; import com.bablsoft.accessflow.core.api.DatasourceAdminService; @@ -21,9 +22,8 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.List; -import java.util.Locale; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -55,7 +55,7 @@ public SelectExecutionResult sample(UUID datasourceId, UUID organizationId, UUID if (!isAdmin) { // Non-admins additionally need read capability + the target inside their allow-list. var view = permission.orElseThrow(() -> new TableNotFoundException(datasourceId, table)); - if (!view.canRead() || !targetAllowed(view, target)) { + if (!view.canRead() || !targetAllowed(view, target, schemaView)) { throw new TableNotFoundException(datasourceId, table); } // The preview reads every column, so a denied column on the table refuses it (#935). @@ -123,59 +123,49 @@ private Optional resolveTarget(DatabaseSchemaView view, String schema, S } /** - * Mirrors the allow-list semantics of {@code DefaultQuerySubmissionService.verifyAllowedTables}: - * empty lists allow everything; otherwise the table (bare or {@code schema.table}) must be in - * {@code allowedTables}, or its schema in {@code allowedSchemas}. + * The allow-list rule the query gate applies ({@link AllowedTables#coveringEntry}), so a preview + * can never read a table the equivalent {@code SELECT} would be refused. The preview always + * names a concrete {@code schema.table}, while a bare {@code allowed_tables} entry covers only an + * unqualified reference — whatever table the database resolves that name to. It therefore admits + * the target only when no other schema in the view has a table of that name, the fail-closed + * rule {@code SchemaViewPermissionFilter} applies to the view itself (#936). */ - private static boolean targetAllowed(DatasourceUserPermissionView permission, Target target) { - var allowedSchemas = normalizeList(permission.allowedSchemas()); - var allowedTables = normalizeList(permission.allowedTables()); + private static boolean targetAllowed(DatasourceUserPermissionView permission, Target target, + DatabaseSchemaView view) { + var allowedSchemas = AllowedTables.normalize(permission.allowedSchemas()); + var allowedTables = AllowedTables.normalize(permission.allowedTables()); if (allowedSchemas.isEmpty() && allowedTables.isEmpty()) { return true; } - var bare = normalize(target.table()); - var qualified = target.schema() == null || target.schema().isBlank() - ? bare - : normalize(target.schema()) + "." + bare; - if (allowedTables.contains(bare) || allowedTables.contains(qualified)) { + var bare = AllowedTables.normalizeEntry(target.table()); + if (bare == null) { + return false; + } + var schema = AllowedTables.normalizeEntry(target.schema()); + if (schema == null) { + return allowedTables.contains(bare); + } + if (AllowedTables.coveringEntry(allowedSchemas, allowedTables, schema + "." + bare) != null) { return true; } - return target.schema() != null && !target.schema().isBlank() - && allowedSchemas.contains(normalize(target.schema())); + return allowedTables.contains(bare) && tablesNamed(view, bare) == 1; } - private static List normalizeList(List raw) { - if (raw == null || raw.isEmpty()) { - return List.of(); - } - var out = new ArrayList(raw.size()); - for (String entry : raw) { - if (entry == null) { - continue; - } - var normalized = normalize(entry); - if (!normalized.isEmpty()) { - out.add(normalized); + private static int tablesNamed(DatabaseSchemaView view, String bare) { + var count = 0; + for (var ns : view.schemas()) { + for (var t : ns.tables()) { + if (bare.equals(AllowedTables.normalizeEntry(t.name()))) { + count++; + } } } - return List.copyOf(out); + return count; } private static String qualifiedName(Target target) { - return target.schema() == null || target.schema().isBlank() - ? normalize(target.table()) - : normalize(target.schema()) + "." + normalize(target.table()); - } - - private static String normalize(String raw) { - var stripped = new StringBuilder(raw.length()); - for (int i = 0; i < raw.length(); i++) { - char c = raw.charAt(i); - if (c == '"' || c == '`' || c == '[' || c == ']') { - continue; - } - stripped.append(c); - } - return stripped.toString().trim().toLowerCase(Locale.ROOT); + var table = Objects.requireNonNullElse(AllowedTables.normalizeEntry(target.table()), ""); + var schema = AllowedTables.normalizeEntry(target.schema()); + return schema == null ? table : schema + "." + table; } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java index cfca81e02..ea03777ba 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java @@ -284,6 +284,103 @@ void rowLimitPolicyOnTheSampledTableCapsThePreview() { assertThat(captor.getValue().maxRowsOverride()).isEqualTo(7); } + @Test + void bareEntryDoesNotAdmitASchemaQualifiedTargetWhenTheNameIsAmbiguous() { + stubSchemas(schema("public", "orders"), schema("archive", "orders")); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of(), List.of("orders")))); + + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, + "archive", "orders", 50)) + .isInstanceOf(TableNotFoundException.class); + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, + "public", "orders", 50)) + .isInstanceOf(TableNotFoundException.class); + verify(queryExecutor, never()).sampleTable(any()); + } + + @Test + void bareEntryAdmitsTheOnlyTableOfThatName() { + stubSchemas(schema("archive", "orders"), schema("public", "customers")); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of(), List.of("orders")))); + + assertThat(service.sample(datasourceId, organizationId, userId, false, "archive", "orders", + 50)).isSameAs(result); + } + + @Test + void qualifiedEntryDoesNotCoverTheSameNameInAnotherSchema() { + stubSchemas(schema("public", "orders"), schema("archive", "orders")); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of(), + List.of("public.orders")))); + + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, + "archive", "orders", 50)) + .isInstanceOf(TableNotFoundException.class); + assertThat(service.sample(datasourceId, organizationId, userId, false, "public", "orders", + 50)).isSameAs(result); + } + + @Test + void allowedSchemaCoversAnAmbiguousNameInsideIt() { + stubSchemas(schema("public", "orders"), schema("archive", "orders")); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of("archive"), List.of()))); + + assertThat(service.sample(datasourceId, organizationId, userId, false, "archive", "orders", + 50)).isSameAs(result); + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, + "public", "orders", 50)) + .isInstanceOf(TableNotFoundException.class); + } + + @Test + void quotedMixedCaseEntriesAreNormalizedLikeTheQueryGate() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of(), + List.of(" \"PUBLIC\".[Users] ", "")))); + + assertThat(service.sample(datasourceId, organizationId, userId, false, "public", "users", + 50)).isSameAs(result); + } + + @Test + void unnamedSchemaTargetIsCoveredOnlyByABareEntry() { + stubSchemas(schema("", "orders")); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of(), List.of("orders")))); + + assertThat(service.sample(datasourceId, organizationId, userId, false, null, "orders", 50)) + .isSameAs(result); + var captor = ArgumentCaptor.forClass(SampleTableRequest.class); + verify(queryExecutor).sampleTable(captor.capture()); + assertThat(captor.getValue().table()).isEqualTo("orders"); + } + + @Test + void unnamedSchemaTargetIsRefusedWithOnlyAQualifiedEntry() { + stubSchemas(schema("", "orders")); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of("public"), + List.of("public.orders")))); + + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, null, + "orders", 50)) + .isInstanceOf(TableNotFoundException.class); + } + + private void stubSchemas(DatabaseSchemaView.Schema... schemas) { + when(datasourceAdminService.introspectSchema(eq(datasourceId), eq(organizationId), + eq(userId), anyBoolean())).thenReturn(new DatabaseSchemaView(List.of(schemas))); + } + + private static DatabaseSchemaView.Schema schema(String name, String table) { + return new DatabaseSchemaView.Schema(name, List.of(new DatabaseSchemaView.Table(table, + List.of(new DatabaseSchemaView.Column("id", "uuid", false, true)), List.of()))); + } + private DatasourceUserPermissionView permission(boolean canRead, List restrictedColumns, List allowedSchemas, List allowedTables) { diff --git a/docs/05-backend.md b/docs/05-backend.md index bff6afeca..d46091736 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -1027,7 +1027,7 @@ The result is returned via `DatabaseSchemaView` (immutable nested records: `Sche `proxy.api.SampleDataService` returns a bounded, fully-governed sample of a single table's rows for the schema-explorer UI — an **ad-hoc read that bypasses review but not governance**. It does *not* create a `query_request`; it resolves the caller's directives and runs through the executor exactly like `DefaultQueryLifecycleService.doExecute`: -1. **Authorization + allow-list.** `DefaultSampleDataService` calls `DatasourceAdminService.introspectSchema(...)` (which enforces org + permission-row access) and validates the requested `schema`/`table` against the returned `DatabaseSchemaView`. Non-ADMINs additionally need `can_read` and the target inside their `allowed_schemas`/`allowed_tables` (same normalization as `DefaultQuerySubmissionService.verifyAllowedTables`). A miss raises `TableNotFoundException` (HTTP 404) — existence is never leaked. +1. **Authorization + allow-list.** `DefaultSampleDataService` calls `DatasourceAdminService.introspectSchema(...)` (which enforces org + permission-row access) and validates the requested `schema`/`table` against the returned `DatabaseSchemaView`. Non-ADMINs additionally need `can_read` and the target inside their `allowed_schemas`/`allowed_tables`, matched by `core.api.AllowedTables` (`normalize` + `coveringEntry`) — the matcher the query gate (`DatasourcePermissionChecker.rejectedTables`) uses, so a preview can never read a table the equivalent `SELECT` would be refused (#1089). The preview always names a concrete `schema.table`, which the gate covers only through that qualified entry or its schema; a **bare** `allowed_tables` entry covers only an unqualified reference — whatever the database resolves the name to — so it admits a schema-qualified preview target only when no other schema in the introspected view has a table of that name, failing closed otherwise (the rule `core.internal.SchemaViewPermissionFilter` applies to the view itself, #936). `allowed_tables=[orders]` with both `public.orders` and `archive.orders` therefore previews neither until the admin qualifies the entry; a target with no schema name is covered only by a bare entry. A miss raises `TableNotFoundException` (HTTP 404) — existence is never leaked. 2. **Directive resolution.** Restricted columns (from the permission), `ColumnMaskDirective`s (`MaskingPolicyResolutionService`), and `RowSecurityDirective`s (`RowSecurityResolutionService`) are resolved for the caller. 3. **Execution.** `QueryExecutor.sampleTable(SampleTableRequest)` enforces the row cap (`maxRowsOverride` — the requested limit, lowered to the caller's effective `row_limit_override` when one applies (#933) and to any row-limit policy on the sampled table (#934) — clamped to the datasource + global `ACCESSFLOW_PROXY_EXECUTION_MAX_ROWS`) and statement timeout, then: - **Relational** datasources: builds `SELECT * FROM ` (via `IdentifierQuoter`, never raw input) and runs the existing JDBC path — `RowSecurityRewriter` injects RLS, `JdbcResultRowMapper` + `ColumnMasker` mask post-fetch, JDBC `setMaxRows` caps without a dialect-specific `LIMIT`. @@ -1039,7 +1039,7 @@ The result is a `SelectExecutionResult` mapped to `SampleRowsResponse` for `GET `proxy.api.QueryDryRunService` returns a **non-committing execution plan + best-effort estimated row impact** for a query — the playground/sandbox a user reaches for before formal submission (`POST /api/v1/queries/dry-run`). Like the sample path it is an **ad-hoc read that bypasses review but not governance**, creates no `query_request`, and never mutates data — every engine plans the statement (relational `EXPLAIN`, Mongo `explain`, …) but never executes it. -1. **Authorization + allow-list.** `DefaultQueryDryRunService` resolves the datasource via `DatasourceAdminService.getForUser`/`getForAdmin` (org + permission-row access; 404 on miss), parses the query through `QueryParser` (`InvalidSqlException` → 422) for the `QueryType` + `referencedTables`, and — for non-ADMINs — verifies the matching capability (`can_read`/`can_write`/`can_ddl`) and that every referenced table is inside the caller's allow-list (same normalization as `DefaultQuerySubmissionService.verifyAllowedTables`; a miss raises Spring Security `AccessDeniedException` → 403). +1. **Authorization + allow-list.** `DefaultQueryDryRunService` resolves the datasource via `DatasourceAdminService.getForUser`/`getForAdmin` (org + permission-row access; 404 on miss), parses the query through `QueryParser` (`InvalidSqlException` → 422) for the `QueryType` + `referencedTables`, and — for non-ADMINs — verifies the matching capability (`can_read`/`can_write`/`can_ddl`) and that every referenced table is inside the caller's allow-list (`core.api.AllowedTables.coveringEntry`, the query gate's matcher; a miss raises Spring Security `AccessDeniedException` → 403). 2. **Directive resolution.** The caller's `RowSecurityDirective`s (`RowSecurityResolutionService`) are resolved so the plan reflects the **governed** query. Column masks are irrelevant to a plan (no rows are returned) and are omitted. 3. **Planning.** `QueryExecutor.dryRun(QueryExecutionRequest)` applies the `RowSecurityRewriter`, acquires a connection via `RoutingDataSourceResolver` (SELECT dry-runs prefer the read replica; writes plan on the primary — e.g. Oracle writes its scratch `PLAN_TABLE` there), and: - **Relational** datasources: a per-`DbType` `DryRunPlanner` (`proxy/internal/dryrun/`) runs the dialect's non-executing EXPLAIN — PostgreSQL `EXPLAIN (FORMAT JSON)`, MySQL/MariaDB `EXPLAIN FORMAT=JSON`, Oracle `EXPLAIN PLAN FOR` + `PLAN_TABLE` (rows deleted in a `finally`), SQL Server `SET SHOWPLAN_ALL ON` — and maps it to a `QueryPlanNode` tree. `CUSTOM` JDBC has no planner and degrades gracefully. From 22e861ab9fd0e2db07a859a6d23b4f492e661079 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 24 Sep 2026 13:36:31 +0400 Subject: [PATCH 4/4] fix(AF-1089): count bare-entry twins over the unfiltered catalog Refs #1089 --- .../internal/DefaultSampleDataService.java | 23 +++++++----- .../DefaultQueryDryRunServiceTest.java | 34 ++++++++++++++++++ .../DefaultSampleDataServiceTest.java | 36 ++++++++++++++++++- docs/05-backend.md | 2 +- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java index add42233e..808dcc1dc 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java @@ -27,6 +27,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.function.Supplier; @Service @RequiredArgsConstructor @@ -55,7 +56,9 @@ public SelectExecutionResult sample(UUID datasourceId, UUID organizationId, UUID if (!isAdmin) { // Non-admins additionally need read capability + the target inside their allow-list. var view = permission.orElseThrow(() -> new TableNotFoundException(datasourceId, table)); - if (!view.canRead() || !targetAllowed(view, target, schemaView)) { + if (!view.canRead() || !targetAllowed(view, target, + () -> datasourceAdminService.introspectSchemaForSystem(datasourceId, + organizationId))) { throw new TableNotFoundException(datasourceId, table); } // The preview reads every column, so a denied column on the table refuses it (#935). @@ -123,15 +126,17 @@ private Optional resolveTarget(DatabaseSchemaView view, String schema, S } /** - * The allow-list rule the query gate applies ({@link AllowedTables#coveringEntry}), so a preview - * can never read a table the equivalent {@code SELECT} would be refused. The preview always - * names a concrete {@code schema.table}, while a bare {@code allowed_tables} entry covers only an - * unqualified reference — whatever table the database resolves that name to. It therefore admits - * the target only when no other schema in the view has a table of that name, the fail-closed - * rule {@code SchemaViewPermissionFilter} applies to the view itself (#936). + * Matches through {@link AllowedTables#coveringEntry}, the query gate's matcher: the qualified + * target is covered by its own {@code schema.table} entry or by its schema. A bare + * {@code allowed_tables} entry covers only an unqualified reference in the gate — whatever table + * the database resolves that name to — which a preview of a concrete {@code schema.table} cannot + * know. It admits the target only when no other schema in the database has a table of that name + * (the fail-closed rule {@code SchemaViewPermissionFilter} applies to the view, #936), counted + * over the unfiltered catalog, fetched only for this fallback, since the caller's filtered view + * may already hide the other table. A target without a schema is covered by a bare entry only. */ private static boolean targetAllowed(DatasourceUserPermissionView permission, Target target, - DatabaseSchemaView view) { + Supplier fullCatalog) { var allowedSchemas = AllowedTables.normalize(permission.allowedSchemas()); var allowedTables = AllowedTables.normalize(permission.allowedTables()); if (allowedSchemas.isEmpty() && allowedTables.isEmpty()) { @@ -148,7 +153,7 @@ private static boolean targetAllowed(DatasourceUserPermissionView permission, Ta if (AllowedTables.coveringEntry(allowedSchemas, allowedTables, schema + "." + bare) != null) { return true; } - return allowedTables.contains(bare) && tablesNamed(view, bare) == 1; + return allowedTables.contains(bare) && tablesNamed(fullCatalog.get(), bare) == 1; } private static int tablesNamed(DatabaseSchemaView view, String bare) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java index 6a65cbe65..6e39d7bb8 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java @@ -124,6 +124,40 @@ void nonAdminWithNoPermissionIsDenied() { .isInstanceOf(AccessDeniedException.class); } + @Test + void nonAdminReferencingTablesCoveredBySchemaOrQualifiedEntryPasses() { + when(datasourceAdminService.getForUser(datasourceId, orgId, userId)).thenReturn(view()); + when(queryParser.parse(anyString(), any())) + .thenReturn(parse(QueryType.SELECT, Set.of("sales.orders", "public.users"))); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(java.util.Optional.of(permission(true, List.of("Sales"), + List.of("\"PUBLIC\".\"USERS\"")))); + when(rowSecurityResolutionService.resolveApplicable(orgId, datasourceId, userId)) + .thenReturn(List.of()); + var expected = QueryDryRunResult.of("postgresql", QueryType.SELECT, 1L, null, null, + Set.of(), Duration.ZERO); + when(queryExecutor.dryRun(any())).thenReturn(expected); + + assertThat(service.dryRun(datasourceId, "SELECT 1", userId, orgId, false)) + .isSameAs(expected); + } + + @Test + void nonAdminWithAllowListAndNoReferencedTablesPasses() { + when(datasourceAdminService.getForUser(datasourceId, orgId, userId)).thenReturn(view()); + when(queryParser.parse(anyString(), any())).thenReturn(parse(QueryType.SELECT, Set.of())); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(java.util.Optional.of(permission(true, List.of(), List.of("orders")))); + when(rowSecurityResolutionService.resolveApplicable(orgId, datasourceId, userId)) + .thenReturn(List.of()); + var expected = QueryDryRunResult.of("postgresql", QueryType.SELECT, 1L, null, null, + Set.of(), Duration.ZERO); + when(queryExecutor.dryRun(any())).thenReturn(expected); + + assertThat(service.dryRun(datasourceId, "SELECT 1", userId, orgId, false)) + .isSameAs(expected); + } + @Test void nonAdminReferencingDisallowedTableIsDenied() { when(datasourceAdminService.getForUser(datasourceId, orgId, userId)).thenReturn(view()); diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java index ea03777ba..591b19147 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java @@ -286,7 +286,9 @@ void rowLimitPolicyOnTheSampledTableCapsThePreview() { @Test void bareEntryDoesNotAdmitASchemaQualifiedTargetWhenTheNameIsAmbiguous() { + // Defence in depth: even a view that still lists both tables must not admit either. stubSchemas(schema("public", "orders"), schema("archive", "orders")); + stubCatalog(schema("public", "orders"), schema("archive", "orders")); when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(permission(true, List.of(), List.of(), List.of("orders")))); @@ -301,7 +303,8 @@ void bareEntryDoesNotAdmitASchemaQualifiedTargetWhenTheNameIsAmbiguous() { @Test void bareEntryAdmitsTheOnlyTableOfThatName() { - stubSchemas(schema("archive", "orders"), schema("public", "customers")); + stubSchemas(schema("archive", "orders")); + stubCatalog(schema("archive", "orders"), schema("public", "customers")); when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(permission(true, List.of(), List.of(), List.of("orders")))); @@ -309,6 +312,32 @@ void bareEntryAdmitsTheOnlyTableOfThatName() { 50)).isSameAs(result); } + @Test + void bareEntryCountsTheUnfilteredCatalogNotTheCallersView() { + // The filtered view shows archive.orders through a catalog-qualified grant and hides + // public.orders; the bare entry must still see the name is ambiguous in the database. + stubSchemas(schema("archive", "orders")); + stubCatalog(schema("public", "orders"), schema("archive", "orders")); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of(), + List.of("orders", "cat.archive.orders")))); + + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, + "archive", "orders", 50)) + .isInstanceOf(TableNotFoundException.class); + verify(queryExecutor, never()).sampleTable(any()); + } + + @Test + void coveredTargetNeverIntrospectsTheUnfilteredCatalog() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, List.of(), List.of("public"), List.of()))); + + service.sample(datasourceId, organizationId, userId, false, "public", "users", 50); + + verify(datasourceAdminService, never()).introspectSchemaForSystem(any(), any()); + } + @Test void qualifiedEntryDoesNotCoverTheSameNameInAnotherSchema() { stubSchemas(schema("public", "orders"), schema("archive", "orders")); @@ -371,6 +400,11 @@ void unnamedSchemaTargetIsRefusedWithOnlyAQualifiedEntry() { .isInstanceOf(TableNotFoundException.class); } + private void stubCatalog(DatabaseSchemaView.Schema... schemas) { + when(datasourceAdminService.introspectSchemaForSystem(datasourceId, organizationId)) + .thenReturn(new DatabaseSchemaView(List.of(schemas))); + } + private void stubSchemas(DatabaseSchemaView.Schema... schemas) { when(datasourceAdminService.introspectSchema(eq(datasourceId), eq(organizationId), eq(userId), anyBoolean())).thenReturn(new DatabaseSchemaView(List.of(schemas))); diff --git a/docs/05-backend.md b/docs/05-backend.md index d46091736..244edfffa 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -1027,7 +1027,7 @@ The result is returned via `DatabaseSchemaView` (immutable nested records: `Sche `proxy.api.SampleDataService` returns a bounded, fully-governed sample of a single table's rows for the schema-explorer UI — an **ad-hoc read that bypasses review but not governance**. It does *not* create a `query_request`; it resolves the caller's directives and runs through the executor exactly like `DefaultQueryLifecycleService.doExecute`: -1. **Authorization + allow-list.** `DefaultSampleDataService` calls `DatasourceAdminService.introspectSchema(...)` (which enforces org + permission-row access) and validates the requested `schema`/`table` against the returned `DatabaseSchemaView`. Non-ADMINs additionally need `can_read` and the target inside their `allowed_schemas`/`allowed_tables`, matched by `core.api.AllowedTables` (`normalize` + `coveringEntry`) — the matcher the query gate (`DatasourcePermissionChecker.rejectedTables`) uses, so a preview can never read a table the equivalent `SELECT` would be refused (#1089). The preview always names a concrete `schema.table`, which the gate covers only through that qualified entry or its schema; a **bare** `allowed_tables` entry covers only an unqualified reference — whatever the database resolves the name to — so it admits a schema-qualified preview target only when no other schema in the introspected view has a table of that name, failing closed otherwise (the rule `core.internal.SchemaViewPermissionFilter` applies to the view itself, #936). `allowed_tables=[orders]` with both `public.orders` and `archive.orders` therefore previews neither until the admin qualifies the entry; a target with no schema name is covered only by a bare entry. A miss raises `TableNotFoundException` (HTTP 404) — existence is never leaked. +1. **Authorization + allow-list.** `DefaultSampleDataService` calls `DatasourceAdminService.introspectSchema(...)` (which enforces org + permission-row access) and validates the requested `schema`/`table` against the returned `DatabaseSchemaView`. Non-ADMINs additionally need `can_read` and the target inside their `allowed_schemas`/`allowed_tables`, matched by `core.api.AllowedTables` (`normalize` + `coveringEntry`) — the query gate's matcher (`DatasourcePermissionChecker.rejectedTables`), so a `schema.table` or `allowed_schemas` grant covers the preview exactly as it covers a `SELECT` (#1089). A **bare** `allowed_tables` entry is the one place the two differ: the gate applies it to an *unqualified* reference, which the database resolves, while the preview reads a concrete `schema.table`. The preview admits it only when no other schema in the database has a table of that name — counted over the unfiltered catalog (`introspectSchemaForSystem`, fetched only for this fallback), since the caller's #936-filtered view may already hide the twin — and otherwise fails closed, matching `core.internal.SchemaViewPermissionFilter`. So `allowed_tables=[orders]` with both `public.orders` and `archive.orders` previews neither until the admin qualifies the entry, and a target whose schema reports no name is covered by a bare entry only. The fallback is deliberately looser than the gate in one case: with `orders` only in `archive` and `archive` off the search path, the preview reads `archive.orders` while an unqualified `SELECT * FROM orders` fails to resolve — the same table the schema tree already shows. The view's catalog-suffix rule (`mydb.dbo.orders` showing `dbo.orders`) is not honoured here, so such a table is listed but its preview returns 404. A miss raises `TableNotFoundException` (HTTP 404) — existence is never leaked. 2. **Directive resolution.** Restricted columns (from the permission), `ColumnMaskDirective`s (`MaskingPolicyResolutionService`), and `RowSecurityDirective`s (`RowSecurityResolutionService`) are resolved for the caller. 3. **Execution.** `QueryExecutor.sampleTable(SampleTableRequest)` enforces the row cap (`maxRowsOverride` — the requested limit, lowered to the caller's effective `row_limit_override` when one applies (#933) and to any row-limit policy on the sampled table (#934) — clamped to the datasource + global `ACCESSFLOW_PROXY_EXECUTION_MAX_ROWS`) and statement timeout, then: - **Relational** datasources: builds `SELECT * FROM ` (via `IdentifierQuoter`, never raw input) and runs the existing JDBC path — `RowSecurityRewriter` injects RLS, `JdbcResultRowMapper` + `ColumnMasker` mask post-fetch, JDBC `setMaxRows` caps without a dialect-specific `LIMIT`.