Skip to content

fix(security): key column-values and annotation caches on RLS of tables inside virtual dataset SQL - #45814

Merged
aminghadersohi merged 12 commits into
masterfrom
fix-column-values-cache-inner-rls
Oct 28, 2026
Merged

aminghadersohi merged 12 commits into
masterfrom
fix-column-values-cache-inner-rls

Conversation

@aminghadersohi

@aminghadersohi aminghadersohi commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

SUMMARY

What. Two shared caches now key on the RLS predicates of the tables read inside a virtual dataset's SQL (and a SQL Lab query's SQL), not only on the datasource's own RLS:

  1. The column-values cache (_column_values_cache_key in superset/datasource/api.py), used by the filter-values endpoint and by the MCP dashboard-filter validation through get_cached_column_values.
  2. The annotation data cached on a parent chart's entry (QueryContextProcessor._annotation_cache_context), for chart-backed annotation layers whose source chart is on a virtual dataset.

Why. Both keys carried only security_manager.get_rls_cache_key(datasource), which is the datasource's own RLS. Executing a virtual dataset (get_from_clause) also injects the RLS of every table its SQL reads.

  • Column values: two viewers with the same rules on the virtual dataset but different rules on an inner table got the same key, and the second viewer was served the first viewer's cached filter values.
  • Annotations: the context also binds the requesting user id, but every embedded guest has no user id. Two guests whose token rules differ only on a table inside the source dataset's SQL got the same context, so one could be served the other's annotation data.

How.

  • The inner-SQL predicate collection the chart cache key already uses (SqlaTable.get_extra_cache_keys / Query.get_extra_cache_keys, via collect_rls_predicates_for_sql) moves into a new get_inner_rls_cache_key() method on SqlaTable and Query. The chart cache key calls it too. The column-values key adds it as inner_rls, and the annotation context adds it per layer as source_inner_rls, so all three use the same predicates.
  • Each predicate is bound to the table it applies to (rls-table:[catalog, schema, table, predicate]), so viewers whose rules are swapped between two inner tables get different keys. The chart cache key used the bare predicates, so it had the same collision: two such viewers shared a cached chart result. As a result, chart-data entries of virtual datasets (and annotations) with inner rules are recomputed once after upgrade.
  • If the predicates cannot be collected, collect_rls_predicates_for_sql already returns a viewer-scoped marker (per user id, or per guest-token RLS for guests), so the keys fail closed to per-viewer.
  • The new fields are only added when non-empty, so physical datasets, semantic views and virtual datasets with no inner rules keep their existing key bytes (no cache churn).
  • The annotation source datasource lookup moves into _annotation_source_datasource (same behavior) to keep _annotation_cache_context under the complexity limit.
  • Templated virtual SQL whose raw text does not parse (e.g. {% if %} blocks) gets the viewer-scoped marker, so those entries are cached per viewer. The chart cache key already behaves this way for the same datasets, because it uses the same method.
  • Building the key does not connect to the database for a dataset or query with a schema: the default schema is looked up only when there is none. Cache hits cost one metadata lookup of the RLS rules for the inner tables, and cached values are still served when the database is unreachable.

Cache keys built from get_rls_cache_key(:

  • superset/datasource/api.py _column_values_cache_key: fixed here.
  • QueryContextProcessor._annotation_cache_context: fixed here.
  • QueryContextProcessor.query_cache_key: not affected, it also adds datasource.get_extra_cache_keys(...), which includes the inner RLS (now through the same method).
  • semantic_layers/cache_host.py _execution_context and _scope_material: not affected, semantic views have no Superset-executed inner SQL to inject RLS into.

This touches the same block of SqlaTable.get_extra_cache_keys / Query.get_extra_cache_keys as #45681; whichever merges second will need a small merge.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A

TESTING INSTRUCTIONS

New file tests/unit_tests/security/inner_rls_cache_key_test.py, on real SQLite: a physical table t (rows tenant=1 'alpha', tenant=2 'beta'), a virtual dataset SELECT tenant, name FROM t, viewers (or embedded guests whose token rule is scoped to t) with tenant = 1 vs tenant = 2, and no rules on the virtual dataset itself.

Column values:

  • test_inner_table_rls_partitions_column_values_cache_key
  • test_inner_table_rls_viewers_only_see_their_own_cached_values
  • test_query_inner_table_rls_partitions_column_values_cache_key
  • test_inner_rls_collection_failure_falls_back_to_viewer_scoped_key
  • test_same_inner_table_rls_shares_column_values_cache_key
  • test_physical_dataset_key_has_no_inner_rls_component
  • test_cached_column_values_are_served_without_reaching_the_database

Chart cache key:

  • test_chart_cache_key_carries_the_same_inner_rls (pins the refactored path for SqlaTable and Query)

Annotation data:

  • test_annotation_context_partitions_guests_by_inner_table_rls
  • test_annotation_context_falls_back_to_viewer_scoped_key
  • test_annotation_context_shares_entry_for_equal_inner_table_rls
  • test_annotation_context_shape_unchanged_without_inner_rls

Before the fix (source files at master a22013d37945c66aab94a70b3234f22b595fbefc, same test file):

E       AssertionError: assert (['alpha'], True) == (['beta'], False)
E       AssertionError: assert {'semantic_filter_protocol': 'semantic-null-filters-v1', 'source_rls': {'1': []}, 'source_versions': {}, 'user_id': None} != {'semantic_filter_protocol': 'semantic-null-filters-v1', 'source_rls': {'1': []}, 'source_versions': {}, 'user_id': None}
FAILED ...::test_inner_table_rls_partitions_column_values_cache_key
FAILED ...::test_inner_table_rls_viewers_only_see_their_own_cached_values
FAILED ...::test_query_inner_table_rls_partitions_column_values_cache_key
FAILED ...::test_inner_rls_collection_failure_falls_back_to_viewer_scoped_key
FAILED ...::test_chart_cache_key_carries_the_same_inner_rls
FAILED ...::test_annotation_context_partitions_guests_by_inner_table_rls
FAILED ...::test_annotation_context_falls_back_to_viewer_scoped_key
PASSED ...::test_same_inner_table_rls_shares_column_values_cache_key
PASSED ...::test_physical_dataset_key_has_no_inner_rls_component
PASSED ...::test_annotation_context_shares_entry_for_equal_inner_table_rls
PASSED ...::test_annotation_context_shape_unchanged_without_inner_rls
7 failed, 4 passed

The four that pass on master are non-regression pins: viewers with the same effective RLS still share an entry, and keys and contexts without inner rules are unchanged.

After the fix:

11 passed
pytest tests/unit_tests/security/inner_rls_cache_key_test.py
pytest tests/unit_tests/security tests/unit_tests/common tests/unit_tests/datasource tests/unit_tests/models tests/unit_tests/connectors tests/unit_tests/utils/rls_test.py tests/unit_tests/semantic_layers tests/unit_tests/sql_lab_test.py tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py tests/unit_tests/mcp_service/chart tests/unit_tests/charts
pytest tests/unit_tests/sql --cov=superset/sql --cov-fail-under=100

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

…ual dataset SQL

The shared column-values cache keyed only on the outer dataset's RLS
(security_manager.get_rls_cache_key). Executing a virtual dataset (or a
SQL Lab query) also injects the RLS of every table its SQL reads, so two
viewers with the same rules on the virtual dataset but different rules on
an inner table shared one entry and one could be served the other's values.

Move the inner-SQL predicate collection the chart cache key already uses
into SqlaTable/Query.get_inner_rls_cache_key and include it in the
column-values key. Collection failure falls back to the existing
viewer-scoped marker. The field is only added when non-empty, so keys
without inner rules keep their bytes.
@github-actions github-actions Bot added the api Related to the REST API label Oct 28, 2026
… SQL

Annotation data from a chart-backed layer is cached on the parent chart's
entry, keyed by the requesting user id and the source datasource's own RLS.
Embedded guests all have no user id, so two guests whose token rules differ
only on a table read inside the source's virtual dataset SQL shared one
entry and one could be served the other's annotation data.

Add the source's inner-SQL RLS (get_inner_rls_cache_key, the collection the
chart cache key uses) to the annotation context, failing closed to the
viewer-scoped marker, and only when non-empty so other contexts keep their
bytes. The source datasource lookup moves into its own helper.

The column-values cache tests move to a module covering every inner-RLS
cache key.
@codecov

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.67925% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.61%. Comparing base (463c58c) to head (3f65d92).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
superset/common/query_context_processor.py 82.60% 3 Missing and 1 partial ⚠️
superset/models/sql_lab.py 81.81% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #45814      +/-   ##
==========================================
- Coverage   84.62%   84.61%   -0.01%     
==========================================
  Files        3069     3069              
  Lines      205465   205493      +28     
  Branches    48294    48301       +7     
==========================================
+ Hits       173868   173886      +18     
- Misses      28303    28313      +10     
  Partials     3294     3294              
Flag Coverage Δ
hive 34.49% <18.86%> (-0.01%) ⬇️
mysql 52.51% <49.05%> (-0.01%) ⬇️
postgres 52.51% <49.05%> (-0.01%) ⬇️
presto 36.22% <26.41%> (-0.01%) ⬇️
python 87.73% <88.67%> (+<0.01%) ⬆️
sqlite 52.28% <49.05%> (-0.01%) ⬇️
unit 82.45% <88.67%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@aminghadersohi aminghadersohi changed the title fix(datasource): key column-values cache on RLS of tables inside virtual dataset SQL fix(security): key column-values and annotation caches on RLS of tables inside virtual dataset SQL Oct 28, 2026
…without one

get_inner_rls_cache_key looked up the database's default schema before
collecting the inner RLS predicates, even for datasets with a schema. The
lookup opens a connection to the database, so every column-values request
on a virtual dataset (or SQL Lab query) connected to it, cache hits
included, and a cache hit failed with a 500 when the database was
unreachable. Look it up only when the dataset has no schema.
@aminghadersohi

Copy link
Copy Markdown
Contributor Author

Product acceptance run on a real stack (docker compose: Postgres metadata + warehouse, Redis data cache, embedded on, roles with RLS only on the physical tables read by the virtual datasets), comparing the merge base a22013d37945c66aab94a70b3234f22b595fbefc with this branch over real HTTP sessions.

Result: all five cases pass on the branch and the leaks reproduce at the merge base:

Case Merge base Branch
Column values on a virtual dataset, viewers with different rules on the inner table second viewer gets a cache HIT with the first viewer's values (8 of the first tenant's values, 0 of its own) own entry, own values
Same, via search (q=), a SQL Lab query datasource, and the MCP filter validation helper HIT with the other viewer's values own values
Two embedded guests, token rules differing only on the inner table, chart-backed annotation second guest is_cached: true with the first guest's annotation (57394.5; its own is 57396.0) own annotation, 57396.0
Same two predicates swapped between two inner tables column values HIT ['North'] for the 'South' viewer; chart data also served from the other viewer's cached result own values and own chart result
Templated virtual SQL that does not parse raw HIT with another viewer's values per-viewer entries
Identical effective RLS shared still shared (cache HIT)
Physical datasets — column-values Redis keys and chart cache keys byte-identical to the merge base

One issue found and fixed in 1072cc7: get_inner_rls_cache_key looked up the database's default schema before collecting predicates, even for datasets with a schema. That lookup opens a connection, so every column-values request on a virtual dataset connected to the warehouse, cache hits included. With the warehouse stopped, the merge base still served cached filter values (HIT) while 6e2d289 returned a 500. The default schema is now looked up only when the dataset or query has no schema.

  • Regression test: test_cached_column_values_are_served_without_reaching_the_database fails at 6e2d289 (ConnectionError) and passes at 1072cc7. In the stack, with the warehouse stopped, the cached values are served again (HIT).
  • Searched with grep -rn "get_default_schema(" superset.
  • Siblings checked:
    • superset/connectors/sqla/models.py:3254 — fixed in 1072cc7
    • superset/models/sql_lab.py:426 — fixed in 1072cc7 (the chart cache key and the annotation context call these two methods)
    • superset/utils/rls.py:222 — not affected: only reached for datasets registered without a schema, inside the fail-closed try
    • superset/models/helpers.py:4128 — not affected: query execution, which connects anyway

I also updated the description: binding predicates to their table changes the chart cache key for virtual datasets with inner rules (one-time recompute after upgrade), and it closes the swapped-rules collision in the chart-data cache too.

@aminghadersohi
aminghadersohi marked this pull request as ready for review October 28, 2026 12:16
Comment thread superset/connectors/sqla/models.py Resolved
Comment thread superset/datasource/api.py Resolved
@codeant-ai-for-open-source

codeant-ai-for-open-source Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

CodeAnt  CodeAnt PR Risk

🟢  Low Risk

  • The PR appears safe to merge; schema lookup failures now use viewer-scoped keys instead of aborting column-value or annotation cache reads.
  • Regression tests cover schema outages and verify that different viewer scopes do not share cached results.

Assessed commit: 3f65d928bbb4

@gabotorresruiz gabotorresruiz left a comment

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.

Hey @aminghadersohi, thanks for this one, the key itself checks out. Every shape I pushed through get_inner_rls_cache_key produced components matching what apply_rls actually injects, table for table: joins, CTEs, a CTE that is never selected from, nested and scalar subqueries, unions, and a CTE name that shadows a registered dataset. Unparseable SQL falls back to the viewer-scoped marker on both the column-values and the annotation path, and physical datasets plus virtual datasets with no inner rules keep byte-identical keys.

Approving, with three notes on the lines. None of them blocks: one is a gap in rls that predates this PR, and two are tests.

One housekeeping thing: the description's "How" section still describes include_table_identity and a rls-table:[...] component, both of which the merge with master replaced, so the mechanism a reviewer reads there no longer matches the code.

CI on 091ed90 is green: 57 successful checks, no failures and nothing still running.

Comment thread superset/datasource/api.py Resolved
@github-actions github-actions Bot added the doc Namespace | Anything related to documentation label Oct 28, 2026
@netlify

netlify Bot commented Oct 28, 2026

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 3f65d92
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6ae25bcae65dedda6c03604c
😎 Deploy Preview https://deploy-preview-45814--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@aminghadersohi
aminghadersohi merged commit 357c801 into master Oct 28, 2026
81 checks passed
@aminghadersohi
aminghadersohi deleted the fix-column-values-cache-inner-rls branch October 28, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the REST API doc Namespace | Anything related to documentation size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants