Skip to content

fix(rls): enforce row-level security on ad-hoc SQL chart creation - #43090

Open
eugeneo17 wants to merge 1 commit into
apache:masterfrom
CloudGeometry:fix/33346-rls-adhoc-sql
Open

fix(rls): enforce row-level security on ad-hoc SQL chart creation #43090
eugeneo17 wants to merge 1 commit into
apache:masterfrom
CloudGeometry:fix/33346-rls-adhoc-sql

Conversation

@eugeneo17

Copy link
Copy Markdown

SUMMARY

Addresses #33346.

Row-Level Security is enforced on the SQL Lab query path but bypassed when a chart is created from that same ad-hoc SQL, silently rendering unfiltered data (the issue reproduces with 3 filtered rows in SQL Lab vs. 125 unfiltered in the resulting chart). In embedded / multi-tenant deployments, where RLS is the tenant boundary, this is a cross-tenant disclosure, and it is silent — neither the restricted analyst nor an admin can detect the over-exposure from the UI.

The root cause is path divergence: charts built from SQL Lab use a Query datasource whose is_rls_supported is False and whose get_sqla_row_level_filters() was a stub returning [] (superset/models/helpers.py), and the RLS cache key was skipped for Query datasources (superset/security/manager.py). Superset already has two separate RLS mechanisms (SQL-rewriting in SQL Lab; WHERE-injection for charts) and the chart-from-SQL path fell between them.

This PR converges enforcement to a single, fail-closed, server-side gate (superset/security/rls_enforcement.py) that every ad-hoc-SQL datasource render is routed through. It rewrites the ad-hoc SQL over the referenced RLS-governed tables at query-build time (superset/utils/rls.py), so enforcement is path-independent; fails closed with a fixed, non-disclosive message when RLS cannot be safely resolved (the sensitive denial reason stays server-side); fixes the RLS cache-key identity so filtered and unfiltered results can no longer collide; records queryable enforcement evidence (new model + REST API + DB migration + CLI); and surfaces enforcement in the UI (an "RLS active" badge and a dedicated RLS error message). Behaviour is unchanged when no RLS applies.

WORTH NOTING

  • Column-level security is out of scope by design. The gate is CLS-ready, but the masking seam intentionally raises NotImplementedError("Column masking is not yet supported") — row-level enforcement is complete; column masking is a documented future seam.
  • The evidence tamper-evidence hash chain is opt-in / off by default (RLS_EVIDENCE_HASH_CHAIN_ENABLED = False). Evidence rows are always recorded; the cross-row integrity chain is only populated when enabled.
  • The evidence migration's down_revision targets the branch base. Before merge it must be rebased onto current master and re-pointed to the current single migration head, or check-db-migration-confict will flag it.
  • I couldn't exercise the full suite / a live DB across engines here — upgrade & downgrade of the new table and the integration tests need a CI run.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A for the enforcement change (backend security fix). The UI adds an "RLS active" badge on charts backed by ad-hoc SQL over governed tables.

TESTING INSTRUCTIONS

pytest tests/unit_tests/security/test_rls_enforcement.py
tests/unit_tests/security/test_fail_closed_decision.py
tests/unit_tests/security/test_rls_cache_key_identity.py
tests/unit_tests/models/test_helpers_rls_gate.py
pytest tests/integration_tests/security/rls_cache_isolation_tests.py
tests/integration_tests/security/row_level_security_tests.py

New tests cover the guest/embedded variant, cache isolation, the fail-closed decision, non-disclosive denial, and a zero-impact baseline. Before merge: reproduce #33346 end-to-end (RLS rule → restricted user → SQL Lab filtered → Create chart → chart shows the same filtered rows, not the full set).

Charts created from SQL Lab ad-hoc SQL bypassed Row-Level Security that
SQL Lab itself enforced, silently rendering unfiltered data (issue
apache#33346: 3 filtered rows in SQL Lab, 125 unfiltered in the chart). In
embedded / multi-tenant deployments where RLS is the tenant boundary
this is a cross-tenant disclosure.

Root cause is path divergence: charts built from SQL Lab use a Query
datasource whose is_rls_supported is False and whose
get_sqla_row_level_filters() was a stub returning [], and the RLS cache
key skipped Query datasources.

This routes every ad-hoc-SQL datasource render through a single,
fail-closed, server-side enforcement gate (superset/security/
rls_enforcement.py) that rewrites the ad-hoc SQL over the referenced
RLS-governed tables, fixes the cache-key identity, surfaces a
non-disclosive denial to the user, and records queryable enforcement
evidence. Adds a CLI, a DB migration for the evidence table, frontend
RLS badge / error surfacing, and adversarial tests.

Addresses apache#33346
@dosubot dosubot Bot added authentication:row-level-security Related to Row Level Security change:backend Requires changing the backend change:frontend Requires changing the frontend labels Aug 12, 2026
@bito-code-review

bito-code-review Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped - Large PR

Bito didn't auto-review this change because the pull request exceeded the line limit. No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.

@github-actions github-actions Bot added risk:db-migration PRs that require a DB migration doc Namespace | Anything related to documentation packages labels Aug 12, 2026
@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

👷 Deploy Preview for superset-docs-preview processing.

Name Link
🔨 Latest commit 86b4341
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a7c7889bba0d30008541465

Comment on lines +76 to +94
from superset import db
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database

_setup_metadata(session)
database = Database(database_name="d", sqlalchemy_uri="sqlite://")
physical = SqlaTable(
table_name="governed_orders",
schema="main",
database=database,
)
virtual = SqlaTable(
table_name="my_saved_chart",
schema="main",
database=database,
sql="SELECT * FROM governed_orders",
)
db.session.add_all([database, physical, virtual])
db.session.flush()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The helper accepts an in-memory session but persists through the global db.session instead. Consequently, the objects are created in a different database/session than the one whose metadata was initialized, so these tests can fail with missing tables or exercise the application database rather than the supplied isolated session. Use the passed session consistently for both add_all and flush. [api mismatch]

Severity Level: Major ⚠️
- ❌ Virtual-dataset RLS regression tests can fail during fixture setup.
- ⚠️ Tests may exercise application metadata instead of isolated SQLite state.
- ⚠️ Five fallback-RLS test cases depend on this helper.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** tests/unit_tests/security/test_fallback_rls_virtual_dataset.py
**Line:** 76:94
**Comment:**
	*Api Mismatch: The helper accepts an in-memory `session` but persists through the global `db.session` instead. Consequently, the objects are created in a different database/session than the one whose metadata was initialized, so these tests can fail with missing tables or exercise the application database rather than the supplied isolated session. Use the passed `session` consistently for both `add_all` and `flush`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +50 to +55
def _sqlite_session() -> Session:
"""Real SQLite session backed by the evidence model's own metadata."""
engine = create_engine("sqlite://", future=True)
RlsEnforcementEvidence.__table__.create(bind=engine)
factory = sessionmaker(bind=engine, future=True)
return factory()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The SQLite fixture creates the evidence table with the model's BigInteger primary key and relies on autoincrement. SQLite only auto-generates identifiers for an INTEGER PRIMARY KEY; this column is emitted as BIGINT, so inserts omit a required id, causing record_evidence to fail and swallow the error, leaving no rows for the evidence and hash-chain assertions. Use a SQLite-compatible integer primary key for this test table or explicitly provide identifiers. [type error]

Severity Level: Major ⚠️
- ❌ Evidence persistence tests fail to observe inserted rows.
- ❌ Hash-chain tests cannot create predecessor-linked records.
- ⚠️ Sink failures are swallowed, masking the SQLite fixture defect.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** tests/unit_tests/security/test_rls_evidence_sink.py
**Line:** 50:55
**Comment:**
	*Type Error: The SQLite fixture creates the evidence table with the model's `BigInteger` primary key and relies on autoincrement. SQLite only auto-generates identifiers for an `INTEGER PRIMARY KEY`; this column is emitted as `BIGINT`, so inserts omit a required `id`, causing `record_evidence` to fail and swallow the error, leaving no rows for the evidence and hash-chain assertions. Use a SQLite-compatible integer primary key for this test table or explicitly provide identifiers.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +101 to +117
engine = create_engine("sqlite://", future=True)
_create_table(engine)
factory = sessionmaker(bind=engine, future=True)
session: Session = factory()

ts = datetime(2026, 8, 7, 12, 0, 0, tzinfo=timezone.utc).replace(tzinfo=None)
row = RlsEnforcementEvidence(
ts=ts,
path="embedded_guest",
identity_handle="opaque-handle",
datasource_kind="query",
datasource_id=42,
outcome="applied",
applied_filter_count=2,
)
session.add(row)
session.commit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The test creates the model on SQLite even though its auto-incrementing primary key is declared as BigInteger; SQLite only auto-generates row IDs for an exact INTEGER PRIMARY KEY, so the subsequent insert can fail with a NOT NULL constraint error instead of round-tripping. Use a SQLite-compatible integer variant for this test or run the model insert against a database that supports the declared type. [type error]

Severity Level: Major ⚠️
- ❌ RLS evidence model tests fail during SQLite insertion.
- ⚠️ CI cannot validate evidence row persistence.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** tests/unit_tests/models/test_rls_evidence.py
**Line:** 101:117
**Comment:**
	*Type Error: The test creates the model on SQLite even though its auto-incrementing primary key is declared as `BigInteger`; SQLite only auto-generates row IDs for an exact `INTEGER PRIMARY KEY`, so the subsequent insert can fail with a NOT NULL constraint error instead of round-tripping. Use a SQLite-compatible integer variant for this test or run the model insert against a database that supports the declared type.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +159 to +168
mocker.patch(
"flask_appbuilder.security.decorators.verify_jwt_in_request",
return_value=True,
)
mocker.patch.object(security_manager, "is_item_public", return_value=False)
mocker.patch.object(security_manager, "has_access", return_value=False)

response = client.get(EVIDENCE_URL)

assert response.status_code in (401, 403)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This negative test never establishes an authenticated user: patching verify_jwt_in_request to return True does not populate the request's authentication context, and the plain test client remains anonymous. The endpoint can therefore return 401 before evaluating has_access, allowing the test to pass even if the dedicated permission check is broken. Authenticate a user with valid access to the application but without the evidence permission, then assert the permission denial. [security]

Severity Level: Major ⚠️
- ⚠️ Authorization test can pass without checking permissions.
- ❌ Dedicated evidence-permission regressions may go undetected.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** tests/unit_tests/security/test_rls_evidence_api.py
**Line:** 159:168
**Comment:**
	*Security: This negative test never establishes an authenticated user: patching `verify_jwt_in_request` to return `True` does not populate the request's authentication context, and the plain test client remains anonymous. The endpoint can therefore return 401 before evaluating `has_access`, allowing the test to pass even if the dedicated permission check is broken. Authenticate a user with valid access to the application but without the evidence permission, then assert the permission denial.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread superset/cli/rls.py
Comment on lines +83 to +89
predicates = collect_rls_predicates_for_sql(
sql,
database,
source.catalog,
schema,
)
return bool(predicates)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The discovery command silently omits artifacts whose SQL cannot be parsed or whose database dialect is unsupported. collect_rls_predicates_for_sql() converts those failures into an empty list, while the enforcement gate denies such queries fail-closed, so the report falsely claims they are safe and is incomplete for migration readiness. Treat resolution failures as at-risk instead of relying only on bool(predicates). [incomplete implementation]

Severity Level: Major ⚠️
- ⚠️ `rls find-at-risk` produces incomplete migration-readiness results.
- ⚠️ Unparseable saved queries are falsely presented as safe.
- ⚠️ Operators cannot identify artifacts that will fail closed after rollout.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/cli/rls.py
**Line:** 83:89
**Comment:**
	*Incomplete Implementation: The discovery command silently omits artifacts whose SQL cannot be parsed or whose database dialect is unsupported. `collect_rls_predicates_for_sql()` converts those failures into an empty list, while the enforcement gate denies such queries fail-closed, so the report falsely claims they are safe and is incomplete for migration readiness. Treat resolution failures as at-risk instead of relying only on `bool(predicates)`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

from superset import security_manager

identity = security_manager.get_current_guest_user_if_guest()
decision = enforce(self, sql, identity, "get_query_str_extended")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The new enforcement call double-applies RLS for Query datasources: get_sqla_query() invokes get_from_clause(), which already calls apply_rls() on the virtual SQL, and this gate rewrites the resulting SQL again. This can duplicate predicates, break aliases or dialect-specific SQL, and report an inflated filter count. Apply enforcement at only one of these stages or skip already-rewritten Query SQL. [logic error]

Severity Level: Major ⚠️
- ❌ Governed ad-hoc SQL charts can fail during query construction.
- ⚠️ Rewritten SQL may contain duplicate tenant predicates.
- ⚠️ Dialect-specific virtual dataset queries may become invalid.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/models/helpers.py
**Line:** 1543:1543
**Comment:**
	*Logic Error: The new enforcement call double-applies RLS for `Query` datasources: `get_sqla_query()` invokes `get_from_clause()`, which already calls `apply_rls()` on the virtual SQL, and this gate rewrites the resulting SQL again. This can duplicate predicates, break aliases or dialect-specific SQL, and report an inflated filter count. Apply enforcement at only one of these stages or skip already-rewritten `Query` SQL.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +144 to +150
elif isinstance(datasource, SqlaTable):
# The SqlaTable path already has its row filters injected upstream; the
# gate verifies passage without double-applying, so the SQL is re-emitted
# unchanged.
decision = EnforcementDecision(
outcome=EnforcementOutcome.NOOP, sql=compiled_sql
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The SqlaTable branch always emits NOOP, even when upstream virtual-dataset or physical-dataset processing has already injected RLS. Because record_evidence intentionally persists only APPLIED and DENIED outcomes, every normal dataset render with RLS applied upstream is omitted from the evidence table. Return an applied decision, or otherwise record the upstream enforcement state at this chokepoint. [incomplete implementation]

Severity Level: Major ⚠️
- ⚠️ Normal dataset RLS renders lack enforcement evidence.
- ⚠️ Auditors cannot query complete RLS application history.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/security/rls_enforcement.py
**Line:** 144:150
**Comment:**
	*Incomplete Implementation: The `SqlaTable` branch always emits `NOOP`, even when upstream virtual-dataset or physical-dataset processing has already injected RLS. Because `record_evidence` intentionally persists only `APPLIED` and `DENIED` outcomes, every normal dataset render with RLS applied upstream is omitted from the evidence table. Return an applied decision, or otherwise record the upstream enforcement state at this chokepoint.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +200 to +202
# dynamic_sql — unrendered templating leaves the table set indeterminate.
if any(marker in compiled_sql for marker in _TEMPLATE_MARKERS):
return "dynamic_sql"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The raw substring check treats braces inside legitimate SQL literals or comments as unrendered Jinja. For example, a query selecting or filtering the literal {{ is classified as dynamic_sql and denied even though its table set is fully deterministic. Detect template syntax outside SQL literals/comments, or rely on the parser/template-rendering state instead of scanning the raw SQL text. [incorrect condition logic]

Severity Level: Major ⚠️
- ❌ Valid SQL charts fail when literals contain Jinja delimiters.
- ⚠️ Unrelated comments can trigger security-policy errors.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/security/rls_enforcement.py
**Line:** 200:202
**Comment:**
	*Incorrect Condition Logic: The raw substring check treats braces inside legitimate SQL literals or comments as unrendered Jinja. For example, a query selecting or filtering the literal `{{` is classified as `dynamic_sql` and denied even though its table set is fully deterministic. Detect template syntax outside SQL literals/comments, or rely on the parser/template-rendering state instead of scanning the raw SQL text.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +205 to +208
# cannot parse/transform this engine's SQL, so injection can't be verified.
engine = database.db_engine_spec.engine
if engine not in SQLGLOT_DIALECTS:
return "unsupported_dialect"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The classifier denies every ad-hoc query whose engine is absent from SQLGLOT_DIALECTS before checking whether any RLS rule applies. This changes the documented no-RLS behavior: an otherwise ungoverned query on a supported SQL Lab database is rejected as unsupported_dialect instead of executing unchanged. Determine whether the query is governed first, or preserve the no-rule path for dialects that cannot require rewriting. [api mismatch]

Severity Level: Major ⚠️
- ❌ Ad-hoc charts fail on unmapped SQL Lab databases.
- ⚠️ Ungoverned queries lose documented no-op behavior.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/security/rls_enforcement.py
**Line:** 205:208
**Comment:**
	*Api Mismatch: The classifier denies every ad-hoc query whose engine is absent from `SQLGLOT_DIALECTS` before checking whether any RLS rule applies. This changes the documented no-RLS behavior: an otherwise ungoverned query on a supported SQL Lab database is rejected as `unsupported_dialect` instead of executing unchanged. Determine whether the query is governed first, or preserve the no-rule path for dialects that cannot require rewriting.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +526 to +532
if _hash_chain_enabled():
prev_hash = _latest_chain_hash(session)
row.integrity_prev_hash = prev_hash
row.integrity_hash = compute_evidence_hash(row, prev_hash)

session.add(row)
session.commit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When hash chaining is enabled, concurrent requests can both read the same latest hash before either commits its row. This creates multiple rows with the same predecessor, so the append-only chain forks and verify_evidence_chain rejects the resulting sequence. Serialize predecessor selection and insertion, or use a database lock/transactional sequencing mechanism. [race condition]

Severity Level: Major ⚠️
- ⚠️ Enabled evidence chains become unverifiable during concurrent renders.
- ⚠️ RLS audit integrity cannot distinguish races from tampering.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/security/rls_enforcement.py
**Line:** 526:532
**Comment:**
	*Race Condition: When hash chaining is enabled, concurrent requests can both read the same latest hash before either commits its row. This creates multiple rows with the same predecessor, so the append-only chain forks and `verify_evidence_chain` rejects the resulting sequence. Serialize predecessor selection and insertion, or use a database lock/transactional sequencing mechanism.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

authentication:row-level-security Related to Row Level Security change:backend Requires changing the backend change:frontend Requires changing the frontend doc Namespace | Anything related to documentation packages risk:db-migration PRs that require a DB migration size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant