diff --git a/superset/data_access_rules/api.py b/superset/data_access_rules/api.py index c7c9e27f5b8..fcd54474fbf 100644 --- a/superset/data_access_rules/api.py +++ b/superset/data_access_rules/api.py @@ -31,7 +31,6 @@ from marshmallow import ValidationError from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.data_access_rules.models import DataAccessRule from superset.data_access_rules.schemas import ( - DataAccessRuleListSchema, DataAccessRulePostSchema, DataAccessRulePutSchema, DataAccessRuleShowSchema, @@ -109,7 +108,8 @@ class DataAccessRulesRestApi(BaseSupersetModelRestApi): add_model_schema = DataAccessRulePostSchema() edit_model_schema = DataAccessRulePutSchema() - list_model_schema = DataAccessRuleListSchema() + # Don't use custom list_model_schema - let Flask-AppBuilder handle + # nested relationships via list_columns dot notation show_model_schema = DataAccessRuleShowSchema() openapi_spec_methods = { diff --git a/superset/data_access_rules/utils.py b/superset/data_access_rules/utils.py index 31689c8b4f2..90582b7291a 100644 --- a/superset/data_access_rules/utils.py +++ b/superset/data_access_rules/utils.py @@ -551,14 +551,51 @@ def apply_data_access_rules( if table_cls: cls_rules[qualified_table] = table_cls - # Apply RLS if we have predicates + # Apply CLS first (before RLS) so that hidden columns are removed + # before RLS wraps the query in a subquery + if cls_rules: + # Build schema dict for sqlglot's qualify() to expand SELECT * + # sqlglot expects nested format: {catalog: {schema: {table: {col: type}}}} + # or {schema: {table: {col: type}}} without catalog + table_schemas: dict[str, Any] = {} + for table in cls_rules.keys(): + try: + columns = database.get_columns(table) + col_types = { + col["column_name"]: str(col.get("type", "VARCHAR")) + for col in columns + } + + # Build nested structure for sqlglot + if table.catalog: + if table.catalog not in table_schemas: + table_schemas[table.catalog] = {} + if table.schema: + if table.schema not in table_schemas[table.catalog]: + table_schemas[table.catalog][table.schema] = {} + table_schemas[table.catalog][table.schema][table.table] = col_types + else: + table_schemas[table.catalog][table.table] = col_types + elif table.schema: + if table.schema not in table_schemas: + table_schemas[table.schema] = {} + table_schemas[table.schema][table.table] = col_types + else: + table_schemas[table.table] = col_types + except Exception as ex: + logger.warning( + "Could not fetch schema for table %s: %s", + table, + ex, + ) + + parsed_statement.apply_cls(cls_rules, schema=table_schemas if table_schemas else None) + + # Apply RLS after CLS - RLS wraps the query in a subquery with SELECT * + # which will pick up the already-transformed columns from CLS if rls_predicates: parsed_statement.apply_rls(catalog, schema, rls_predicates, method) - # Apply CLS if we have rules - if cls_rules: - parsed_statement.apply_cls(cls_rules) - def get_allowed_tables( database_name: str, diff --git a/superset/sql/parse.py b/superset/sql/parse.py index 3d4f55010b9..c6743428209 100644 --- a/superset/sql/parse.py +++ b/superset/sql/parse.py @@ -1536,11 +1536,14 @@ class SQLStatement(BaseSQLStatement[exp.Expression]): # Without schema: qualifies single-table queries, partial for JOINs. from sqlglot.optimizer.qualify import qualify + # Only expand stars if schema is provided (from DAR with feature flag enabled) + # to avoid potential errors in other contexts self._parsed = qualify( self._parsed, schema=schema, dialect=self._dialect, validate_qualify_columns=False, + expand_stars=bool(schema), ) transformer = CLSTransformer(rules, self._dialect)