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 00000000..ca63d231 --- /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 0244924f..a6a694e2 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 478a7b93..550748e6 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 4043a252..819f2187 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 00000000..ff4fbc8c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java @@ -0,0 +1,160 @@ +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. 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 { + + 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 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, + bare, ambiguousNames)) { + tables.add(table); + if (bare != null) { + visibleTableNames.add(bare); + } + } else if (bare != null) { + hiddenTableNames.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, + hiddenTableNames)); + } + 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 bare, + Set ambiguousNames) { + if (bare == null) { + return false; + } + if (allowedTables.contains(bare) && !ambiguousNames.contains(bare)) { + return true; + } + if (normalizedSchema == null) { + return false; + } + 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 hiddenTableNames) { + 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, + hiddenTableNames)) { + 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, + Set hiddenTableNames) { + 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; + } + // 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) + && !hiddenTableNames.contains(target); + } + + private static List nullSafe(List list) { + return list == null ? List.of() : list; + } +} 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 3233fb1c..0545b068 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 56fb26c5..808dcc1d 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,12 +22,12 @@ 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; +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)) { + 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,59 +126,51 @@ 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}. + * 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) { - var allowedSchemas = normalizeList(permission.allowedSchemas()); - var allowedTables = normalizeList(permission.allowedTables()); + private static boolean targetAllowed(DatasourceUserPermissionView permission, Target target, + Supplier fullCatalog) { + 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(fullCatalog.get(), 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/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java index 7f2da055..72ede973 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 00000000..fd9c816e --- /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 7408172d..2bea091c 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 66864f5e..d36e5758 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 00000000..5810c112 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java @@ -0,0 +1,180 @@ +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 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(); + 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/proxy/internal/DefaultQueryDryRunServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java index 6a65cbe6..6e39d7bb 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 cfca81e0..591b1914 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,137 @@ void rowLimitPolicyOnTheSampledTableCapsThePreview() { assertThat(captor.getValue().maxRowsOverride()).isEqualTo(7); } + @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")))); + + 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")); + stubCatalog(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 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")); + 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 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))); + } + + 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/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 0ce542f8..beab5f2f 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,36 @@ 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 analystResult = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/schema") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) + .exchange(); + var adminResult = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/schema") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .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"); + 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 71b21be6..89a8c5a3 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 `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 d09397ca..244edfff 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -1021,11 +1021,13 @@ 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. 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) `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 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`. @@ -1037,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. diff --git a/docs/07-security.md b/docs/07-security.md index c76dbc57..bdfc6413 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -760,6 +760,15 @@ 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, 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) 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 16d2bd15..c4dea331 100644 --- a/docs/13-mcp.md +++ b/docs/13-mcp.md @@ -143,11 +143,11 @@ 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. | -| `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 3fdddd62..cb2ba547 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":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":"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 787716e9..e00c1822 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": "2561f6312374", + "generatedAt": "2026-09-24T09:22:48.045Z", + "sourceCommit": "9518af83030350beb8f73fdac7e8731893d5b958", "chunkCount": 586, - "sha256": "8754c11345235a237df17da7a92d4c2a678b4ce8a27e39a7bdead9eb53d14f42", + "sha256": "2561f63123747e9089d17bd0592b6d09b15856388e9ed7262405df00e882d9f7", "quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66", "sources": [ { @@ -205,7 +205,7 @@ "url": "https://accessflow.io/docs/configuration/datasources/", "section": "Reference", "chunks": 15, - "sha256": "7a9107c02bf639b783a0d3b0ac81569ca31f39a73a6ebb02cb9d85be76a62cc5" + "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 87ae405b..34204198 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 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 Schema and ER diagram tabs alongside Configuration /