Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String> normalize(List<String> raw) {
if (raw == null || raw.isEmpty()) {
return List.of();
}
var out = new ArrayList<String>(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<String> allowedSchemas, List<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<DatasourcePermissionView> listPermissions(UUID datasourceId, UUID organizationId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ public static SortedSet<String> rejectedForWholeTable(List<String> 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<String> 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<String> rejected(List<String> denied,
Set<ColumnReference> references) {
var out = new TreeSet<String>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DatabaseSchemaView.Schema>();
var visibleTableNames = new HashSet<String>();
var hiddenTableNames = new HashSet<String>();
for (var schema : view.schemas()) {
var normalizedSchema = AllowedTables.normalizeEntry(schema.name());
var tables = new ArrayList<DatabaseSchemaView.Table>();
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<DatabaseSchemaView.Schema>(visible.size());
for (var schema : visible) {
var tables = new ArrayList<DatabaseSchemaView.Table>(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<String> allowedSchemas, List<String> allowedTables,
String normalizedSchema, String bare,
Set<String> 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<String> ambiguousTableNames(DatabaseSchemaView view) {
var seen = new HashSet<String>();
var ambiguous = new HashSet<String>();
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<String> denied, boolean restricted,
Set<String> visibleTableNames,
Set<String> hiddenTableNames) {
var columns = new ArrayList<DatabaseSchemaView.Column>();
for (var column : nullSafe(table.columns())) {
if (!DeniedColumns.deniesColumn(denied, schema, table.name(), column.name())) {
columns.add(column);
}
}
var foreignKeys = new ArrayList<DatabaseSchemaView.ForeignKey>();
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<String> denied, boolean restricted,
Set<String> visibleTableNames,
Set<String> 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 <T> List<T> nullSafe(List<T> list) {
return list == null ? List.of() : list;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -106,8 +105,8 @@ private void verifyPermission(UUID userId, UUID datasourceId, SqlParseResult par

private void verifyAllowedTables(DatasourceUserPermissionView permission, UUID datasourceId,
Set<String> 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;
}
Expand All @@ -116,14 +115,9 @@ private void verifyAllowedTables(DatasourceUserPermissionView permission, UUID d
}
var rejected = new TreeSet<String>();
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 {}",
Expand All @@ -142,31 +136,6 @@ private static boolean hasCapability(DatasourceUserPermissionView permission, Qu
};
}

private static List<String> normalizeList(List<String> raw) {
if (raw == null || raw.isEmpty()) {
return List.of();
}
var out = new ArrayList<String>(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());
}
Expand Down
Loading
Loading