apache / apache/superset

Explore denied for a SQL Lab query when the user has only `schema_access` (or `catalog_access`)

Open
#43,987 2 comments 0 reactions 0 assignees View on GitHub
#bug
Dominant language
Python
Stars
74.8k
Forks
18.3k
Avg merge
2d 5h
Merged PRs (30d)
685

Description

### Bug description

A non-Admin holding `schema_access` on the schema a SQL Lab query ran against —
but not `database_access`, and not the query's author — is denied on "Create
chart":

```json
{"error_type":"DATASOURCE_SECURITY_ACCESS_ERROR",
"extra":{"datasource":5,"datasource_name":"repro tab","link":null,"owners":[]},
"message":"This endpoint requires the datasource 5, database or `all_datasource_access` permission"}
```

`5` is the **query id**, not a dataset id. `catalog_access` behaves identically.
The two requests behind the button disagree: `POST /api/v1/explore/form_data`
returns **201**, `GET /api/v1/explore/` returns **403**.

Reproduced on a live instance built from master `1b3758d`. This is a narrower
survivor of #39296 / #42389, fixed by #42590 / #42479 for the author and for
`database_access` holders.

### Steps to reproduce

1. Register a database connection. On an engine with catalogs (e.g. Postgres),
schema permissions are catalog-qualified — `[db].[catalog].[schema]`.
Granting the 2-part `[db].[schema]` form silently does nothing.
2. Create a role with the usual Gamma + `sql_lab` permissions plus
**`schema_access` on that one schema only** — no `database_access`,
`all_database_access` or `all_datasource_access`. Assign it to a test user.
3. Have a **different** user run a query in SQL Lab against that schema. This
matters: since #42479/#42590 the author passes via `is_editor()` and the
authorship bypass, so the bug is invisible if the test user is the author.
4. As the test user, open that query and click **Create chart**.

**Expected:** Explore opens — the user holds `schema_access` on every schema the
query touches, which the query branch of `raise_for_access()` already accepts
(step A proves it). **Actual:** 403 as above.

## Root cause

The query branch of `raise_for_access()` has **no terminal `return`**, so a
cleared `Query` falls through into the generic `datasource=` branch it can never
satisfy. Line numbers on `1b3758d`:

- `security/manager.py:4520` `if database and table or query:`
- `:4529` `if self.can_access_database(database): return` — exit for `database_access`
- `:4566` the `allow_query_authorship_bypass` exit — for the author
- `:4694` per-table loop; with `force_dataset_match=False`, `catalog_access` or
`schema_access` accepts a table
- `:4737` `if denied: raise` — **branch ends, falls through**
- `:4757` `if datasource or query_context:` runs with `datasource` = the `Query`
- `:4905` the raise

`commands/explore/get.py:56-92` (`_authorize_datasource`, #42590) passes the
`Query` under **both** kwargs — `datasource=` deliberately, so
`EXTRA_RAISE_FOR_ACCESS_BYPASS` still sees it. Its comment reasons the tail
check is unreachable because the author bypass returns first; that holds only
for the author. `explore/utils.py:54-72` (`check_query_access`, behind step A)
passes `query=` only, never falls through, and returns 201 — hence the split.

Measured in a request context as the denied user:

```
query.perm = [analytics_db].[repro tab](id:4)
query.schema_perm = analytics_db.sales
get_schema_perm(db, catalog, schema) = [analytics_db].[analytics].[sales]

can_access_database(query.database) = False
can_access_schema(query) = False <-- despite the next line
can_access("schema_access", get_schema_perm) = True
can_access("datasource_access", query.perm) = False
is_editor(query) = False

raise_for_access(query=...) -> PASS
_authorize_datasource(query, None) -> DENIED at manager.py:4905
```

Nothing a `Query` offers satisfies the tail branch:

1. `can_access_schema()` (`security/manager.py:2231-2267`) gates the whole
database → catalog → schema hierarchy behind
`isinstance(datasource, BaseDatasource)`. `Query` is an `Explorable`
(`explorables/base.py:181`) but not a `BaseDatasource`, so it returns `False`
— the same grant returns `True` when queried directly, as above.
2. `Query.perm` (`models/sql_lab.py:377-378`) is a synthetic string no role can
be granted.
3. `is_editor()` is True only for the author (since #42479) and Admins.
4. The dashboard-RBAC / guest branches need `form_data`, `None` here.

### Controls (same query, one variable at a time)

| variant | result |
|---|---|
| `schema_access` only, non-author | **DENIED** |
| \+ `database_access` | PASS |
| `database_access` revoked again | **DENIED** |
| `catalog_access` instead, non-author | **DENIED** (query branch alone: PASS) |
| identical role, but **is** the author | PASS |

## Secondary bug: `Query.schema_perm` format mismatch

`models/sql_lab.py:373-374` returns `f"{database_name}.{schema}"` —
`analytics_db.sales` above — while `get_schema_perm()`
(`security/manager.py:2120`) produces `[analytics_db].[analytics].[sales]`, the
form actually stored as a view-menu name. It differs in bracketing and drops the
catalog, so it can never match a granted permission. This is not cosmetic: see
below.

## Is this intentional? Bisected across releases — no

Same fixture on `6.0.0` (just before the `Explorable` refactor #36245), `6.1.0`
(just after), and master `1b3758d`:

| non-Admin holds | 6.0.0 | 6.1.0 | master |
|---|---|---|---|
| `schema_access` only, not author | DENIED | DENIED | DENIED |
| \+ `database_access` | **PASS** | **DENIED** | **PASS** |
| `schema_access` only, **is** author | DENIED | DENIED | **PASS** |

**`database_access` was a 6.1.0 regression.** Before #36245,
`can_access_schema()` had no type gate and worked on a `Query` by duck typing
(`Query` has `.database` and `.catalog`). #36245 added the `isinstance` gate:

```
6.0.0 : can_access_database=True can_access_schema(query)=True -> PASS
6.1.0 : can_access_database=True can_access_schema(query)=False -> DENIED
```

#42590 restored the outcome by routing the Explore GET through the query branch,
not by fixing the gate. On master the gate still answers wrongly, it is just no
longer on the happy path:

```
master, with database_access:
can_access_database : True
can_access_schema(query) : False <- still wrong
>>> EXPLORE: PASS (via the query-branch early return, not via can_access_schema)
```

**`schema_access` was always meant to work.** 6.0.0's `can_access_schema()` did
evaluate `can_access("schema_access", datasource.schema_perm or "")` against a
`Query` — the arm executed, it just never matched. Granting the malformed string
`Query.schema_perm` actually emits flips it on 6.0.0:

```
After granting "analytics_db.sales" as a schema_access permission:
can_access_database : False
can_access_schema(query) : True
>>> EXPLORE: PASS
```

Nobody can hold that permission in practice — `sync_permissions` only ever
creates the canonical form.

**Conclusion:** not policy. The intent to honour database and schema grants on a
SQL Lab query is present in the pre-refactor code; one arm was broken by the
refactor and later routed around rather than repaired, the other has been broken
by a string-format mismatch throughout.

### Suggested directions

- **Minimal:** give the query branch an explicit `return` once it clears with no
denied tables, so a `Query` never reaches the generic `datasource=` check.
`EXTRA_RAISE_FOR_ACCESS_BYPASS` keeps receiving `datasource`.
- **Also worth doing:** let `can_access_schema()` handle any `Explorable` with a
`database` attribute rather than only `BaseDatasource` (undoing the 6.1.0
regression at its source), and make `Query.schema_perm` delegate to
`security_manager.get_schema_perm(...)`. These two go together — either alone
is a no-op.

Happy to open a PR for whichever is preferred.

## Environment

- **Superset version:** master / latest-dev, commit `1b3758d`
- **Python version:** 3.11
- **Node version:** Not applicable
- **Browser:** Not applicable

**Reproduced on live instances**, not analysis-only: `requirements/base.txt` +
`pip install -e .` from checkouts of `1b3758d`, `6.0.0` and `6.1.0` into separate
venvs, each with its own metadata database, PostgreSQL 17 as the queried
database, served by gunicorn. The two requests were driven over HTTP as the test
user; the per-condition numbers were measured in a request context against the
same denied query.

Note: the published `apache/superset:master` image is stale (built from a March
2024 commit) and cannot reproduce this.

### Screenshots/recordings

_No response_

### Superset version

master / latest-dev

### Python version

3.11

### Node version

16

### Browser

Chrome

### Additional context

**Stacktrace.** Superset's logs contain no traceback for this failure — the
`SupersetSecurityException` is caught by the Explore API and returned as a 403
response body, and nothing is logged at ERROR level. The equivalent traceback,
captured in-process by calling the same entry point the Explore GET uses, is:

```
Traceback (most recent call last):
File "superset/commands/explore/get.py", line 86, in _authorize_datasource
security_manager.raise_for_access(
File "superset/security/manager.py", line 4905, in raise_for_access
raise SupersetSecurityException(
superset.exceptions.SupersetSecurityException: This endpoint requires the
datasource 40, database or `all_datasource_access` permission
```

**Prior art searched.** Closed issues sharing the message but with different,
now-fixed paths: #39296 (fixed by #42590), #42389 (fixed by #42479), #4352
(2018, closed as stale). #32219, #27025 and #36019 share the message but not the
cause. #30839 is about schema enforcement during SQL Lab *execution*, not
explore. The docs do not cover this case: the security page states only that
"if the user does not have the `all_datasource_access` permission granted, the
user will only be able to see Slices or explore the data sources that are
granted to them", and says nothing about exploring a SQL Lab query result rather
than a saved dataset.

### Checklist

- [x] I have searched Superset docs and Slack and didn't find a solution to my problem.
- [x] I have searched the GitHub issue tracker and didn't find a similar bug report.
- [x] I have checked Superset's logs for errors and if I found a relevant Python stacktrace, I included it here as text in the "additional context" section.

Contributor guide

Open the contributing guide

Research direction

Start in security/manager.py at the query branch of raise_for_access(), then trace commands/explore/get.py::_authorize_datasource and explore/utils.py::check_query_access to compare their arguments and control flow. Review models/sql_lab.py::Query.schema_perm and can_access_schema() before choosing the fix scope. Done means a non-author with schema_access or catalog_access can open Explore for a query without regressing database_access or authorship behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
authorization, backend, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.