shelkesays opened a new issue, #41623:
URL: https://github.com/apache/superset/issues/41623
## Bug description
The three many-to-many owner association tables (`dashboard_user`,
`slice_user`, `sqlatable_user`) are declared without a
`UniqueConstraint(<resource_id>, user_id)`. Because of this, the same user can
end up inserted multiple times as an owner of the same resource. When the DAO
later tries to remove that owner (e.g. via `PUT /api/v1/dashboard/{id}` with an
`owners` list that excludes that user), SQLAlchemy's relationship diff assumes
a 1:1 mapping and fails silently. The caller sees HTTP 422 with the generic
message `"Dashboard could not be updated."` (and the equivalents for
slices/datasets).
Worth calling out: the sibling `dashboard_slices` table, declared right next
to `dashboard_user` in `superset/models/dashboard.py`, does have
`UniqueConstraint("dashboard_id", "slice_id")`. So the pattern was clearly
known, it just wasn't applied to the ownership tables.
## Affected code
### `superset/models/dashboard.py`, no unique constraint
```python
dashboard_user = Table(
"dashboard_user",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, ForeignKey("ab_user.id", ondelete="CASCADE")),
Column("dashboard_id", Integer, ForeignKey("dashboards.id",
ondelete="CASCADE")),
# missing: UniqueConstraint("dashboard_id", "user_id"),
)
```
Compare to `dashboard_slices` a few lines above in the same file, which
correctly has:
```python
UniqueConstraint("dashboard_id", "slice_id"),
```
### `superset/models/slice.py`, no unique constraint
```python
slice_user = Table(
"slice_user",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, ForeignKey("ab_user.id", ondelete="CASCADE")),
Column("slice_id", Integer, ForeignKey("slices.id", ondelete="CASCADE")),
# missing: UniqueConstraint("slice_id", "user_id"),
)
```
### `superset/connectors/sqla/models.py`, no unique constraint
```python
sqlatable_user = DBTable(
"sqlatable_user",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, ForeignKey("ab_user.id", ondelete="CASCADE")),
Column("table_id", Integer, ForeignKey("tables.id", ondelete="CASCADE")),
# missing: UniqueConstraint("table_id", "user_id"),
)
```
## How I hit this
Reproduced on our 6.1.0 production instance (metadata DB: Postgres 14). We
were offboarding a user and the API refused to remove them as owner from a
specific dashboard. Every other dashboard worked. On inspection:
```sql
SELECT id, user_id, dashboard_id
FROM public.dashboard_user
WHERE dashboard_id = 121 AND user_id = 18;
id | user_id | dashboard_id
-----+---------+--------------
258 | 18 | 121
260 | 18 | 121
```
Two rows for the same `(dashboard_id, user_id)` pair. Once I deleted one of
them, the API PUT worked immediately.
## How duplicates can enter the table (via normal code paths, no direct SQL
required)
Before anyone asks "why would you ever insert directly via SQL", here are
the code paths I found where the API/UI itself can put a duplicate in:
### 1. The API PUT accepts duplicate ids in `owners` and does not dedupe
The Marshmallow schema for the dashboard `owners` field is:
```python
# superset/dashboards/schemas.py
owners = fields.List(fields.Nested(UserSchema(exclude=["username"])))
```
No `unique=True`, no length/uniqueness validator. So `PUT
/api/v1/dashboard/<id>` with a body like `{"owners": [17, 17, 18]}` passes
validation.
That list flows into `populate_owner_list` in `superset/commands/utils.py`:
```python
def populate_owner_list(owner_ids, default_to_user):
owner_ids = owner_ids or []
owners = []
...
for owner_id in owner_ids:
owner = security_manager.get_user_by_id(owner_id)
if not owner:
raise OwnersNotFoundValidationError()
owners.append(owner) # no dedup
return owners
```
The loop appends every id, unfiltered. Result: `dashboard.owners =
[User(17), User(17), User(18)]`. Without a unique constraint on
`dashboard_user`, both rows get inserted at flush. Same story for slices and
datasets (they use the same helper). Same for `populate_owners` on create.
### 2. The import path assigns owners directly from the payload
```python
# superset/connectors/sqla/models.py:701
self.owners = obj.get("owners", [])
```
If an imported YAML/ZIP has duplicate owner entries, they land in the
association table unfiltered.
### 3. `compute_owner_list` re-propagates any existing corruption
```python
# superset/commands/utils.py
owners_ids = (
[owner.id for owner in current_owners] if new_owners is None else
new_owners
)
```
If `current_owners` already contains duplicates from an earlier bad write,
every subsequent PUT (even one that does not touch `owners` in the payload)
rebuilds the list from those duplicated ids and reassigns them. The corruption
is sticky.
### 4. Concurrent PUTs
Two simultaneous PUTs both computing "current + new owner" and appending the
same user, with no DB constraint to reject the second insert. Not the most
common cause but possible.
## Reproduction
The cleanest repro that does not require any DB access at all:
```bash
# As any user who can PUT a dashboard, send a duplicate owner id
curl -X PUT "$SUPERSET_URL/api/v1/dashboard/<dashboard_id>" \
-H "Content-Type: application/json" \
-H "Cookie: $SUPERSET_COOKIE" \
-H "X-CSRFToken: $CSRF" \
-d '{"owners": [17, 17]}'
```
Verify in the metadata DB:
```sql
SELECT id, user_id, dashboard_id
FROM public.dashboard_user
WHERE dashboard_id = <dashboard_id> AND user_id = 17;
-- Two rows.
```
Now try to remove that user via the API:
```bash
curl -X PUT "$SUPERSET_URL/api/v1/dashboard/<dashboard_id>" \
-H "Content-Type: application/json" \
-H "Cookie: $SUPERSET_COOKIE" \
-H "X-CSRFToken: $CSRF" \
-d '{"owners": [<other_owner_ids_without_17>]}'
```
**Actual response:**
```
HTTP 422
{"message":"Dashboard could not be updated."}
```
**Expected response:**
```
HTTP 200 with the updated owners list.
```
(Same repro works for `/api/v1/chart/<id>` against `slice_user`, and for
`/api/v1/dataset/<id>` against `sqlatable_user`.)
## Root cause
The dashboard PUT command loads `dashboard.owners`, which SQLAlchemy dedupes
to a single `User` object. It then computes the diff against the submitted
owners list and tries to `session.remove(user)` from the relationship.
SQLAlchemy issues `DELETE FROM dashboard_user WHERE dashboard_id = ? AND
user_id = ?`, which affects both duplicate rows. The subsequent relationship
refresh finds a state that does not match its diff calculation and raises an
ORM error. The command layer catches it and re-raises as
`DashboardUpdateFailedError`, which surfaces as the generic 422.
If `UniqueConstraint("dashboard_id", "user_id")` had been on
`dashboard_user` (and the equivalents on `slice_user` and `sqlatable_user`),
the duplicate insert would never have succeeded in the first place, and any
future attempt would fail with a clear constraint violation instead of an
opaque downstream ORM error.
## Business impact
1. Any resource with a duplicate row in an ownership table becomes
unremovable from the affected user until someone runs a manual DB cleanup. This
blocks user offboarding, which is what caught us.
2. The error message ("Dashboard could not be updated.") gives no signal
that the underlying metadata table is corrupt. Only DB level inspection reveals
the duplicate. We spent time chasing wrong theories before running the SELECT.
3. Since the schema does not enforce uniqueness AND the command layer does
not dedupe, corruption can accumulate silently just from normal API traffic (a
scripted client that sends the same id twice, a UI double click, an import with
duplicated owner entries).
## Proposed fix (layered, all four layers needed)
A single-layer fix leaves gaps. The right shape is layered, and each layer
catches a failure mode the others do not:
### Layer 1: API schema, reject the malformed request cleanly
Add a Marshmallow validator on the `owners` field in dashboard, chart, and
dataset schemas that rejects duplicate ids with a clear 400. Something like:
```python
def _no_duplicate_owner_ids(value):
ids = [o["id"] for o in value if isinstance(o, dict) and "id" in o]
if len(ids) != len(set(ids)):
raise ValidationError("owners contains duplicate user ids")
owners = fields.List(
fields.Nested(UserSchema(exclude=["username"])),
validate=_no_duplicate_owner_ids,
)
```
So `{"owners": [17, 17]}` returns a 400 with a message the client can act
on, instead of silently going through.
### Layer 2: Command layer, dedupe so callers other than the REST API are
covered
The import path assigns `self.owners = obj.get("owners", [])` directly
(`superset/connectors/sqla/models.py:701`), so validator-only defence would
miss imports. Dedupe once at the top of `populate_owner_list` in
`superset/commands/utils.py`:
```python
def populate_owner_list(owner_ids, default_to_user):
owner_ids = list(dict.fromkeys(owner_ids or [])) # dedupe, preserve
order
...
```
This covers create, update, and any future caller of the helper.
### Layer 3: Alembic migration, dedupe existing corrupted rows
Any deployment that already has duplicates in `dashboard_user` /
`slice_user` / `sqlatable_user` (like ours) needs those cleaned before a unique
constraint can be added, otherwise `ALTER TABLE` blows up. Keep the row with
the lowest `id`:
```sql
DELETE FROM dashboard_user a USING dashboard_user b
WHERE a.dashboard_id = b.dashboard_id
AND a.user_id = b.user_id
AND a.id > b.id;
```
Repeat for `slice_user` (on `slice_id, user_id`) and `sqlatable_user` (on
`table_id, user_id`). Handle MySQL and SQLite dialect quirks in the same
migration.
### Layer 4: DB constraint, safety net so nothing slips past the first three
Add the composite unique constraint at the SQLAlchemy Table definition for
each table, so the schema itself refuses to store a duplicate:
```python
UniqueConstraint("dashboard_id", "user_id"), # in dashboard_user
UniqueConstraint("slice_id", "user_id"), # in slice_user
UniqueConstraint("table_id", "user_id"), # in sqlatable_user
```
This catches raw session inserts, future refactors, race conditions on
concurrent PUTs, and any code path that misses the command-layer helper.
### Why all four
| Layer | Catches | Skipping it would leave... |
|---|---|---|
| Marshmallow validator | Malformed API requests, gives a clean 400 | Ugly
500 / IntegrityError bubbling out of the DB layer when the constraint rejects
it |
| `populate_owner_list` dedupe | Import path, and any non-REST caller of the
helper | Import path corruption still possible |
| Alembic dedupe migration | Rows already in production DBs | The constraint
ALTER blows up on upgrade for any deployment with existing duplicates |
| DB unique constraint | Raw session inserts, race conditions, future
refactors that forget to dedupe | The bug keeps recurring the moment someone
bypasses the command layer |
Happy to open a PR with all four.
## Environment
- Apache Superset **6.1.0** (production incident)
- Also verified on `apache/superset` upstream **master** at commit
`7cc7e9f6e3` (top of tree at time of filing); all three tables still lack the
constraint.
- Metadata DB: **Postgres 14**
- Verified table DDLs directly from the metadata DB, no unique constraint
present.
## Additional context
`dashboard_slices` (dashboard to slice association, in the same
`superset/models/dashboard.py`) has `UniqueConstraint("dashboard_id",
"slice_id")`. The pattern was already there for at least some association
tables, the ownership tables were just missed.
**Prior art:** an in-flight branch
[`sc-107283-versioning-activity-view`](https://github.com/apache/superset/tree/sc-107283-versioning-activity-view)
already tackles Layers 3 and 4 as part of a broader versioning epic. The
relevant chain is 10 commits tagged `sc-105349`, headlined by
[`f537472291`](https://github.com/apache/superset/commit/f537472291568216498b40aa25ff760c513a7db0)
("refactor(db): composite PK on M2M association tables"), followed by 9
follow-ups covering MySQL/SQLite/Postgres migration quirks and review feedback.
Together they:
- Replace the synthetic `id` PK with a composite `PRIMARY KEY (fk1, fk2)` on
all eight pure-junction tables (`dashboard_user`, `slice_user`,
`sqlatable_user`, `dashboard_slices`, `dashboard_roles`,
`report_schedule_user`, `rls_filter_roles`, `rls_filter_tables`).
- Ship an Alembic migration
`2bee73611e32_composite_pk_association_tables.py` with per-dialect dedupe
(Postgres, MySQL ERROR 1093 subquery workaround, SQLite anonymous-constraint
reflection).
- Add 8 duplicate-rejection unit tests, 8 schema-shape assertions, and a
round-trip / idempotency test.
The chain is **not** merged to master yet (`git branch -r --contains
f537472291` shows only the sc-107283 branch). Files touched across the entire
10-commit chain are limited to `UPDATING.md`, the four ORM `Table()`
definitions, the Alembic migration, the three test files, and a
docker-compose/MySQL init tweak.
What the chain does **not** cover, and what still needs the Layer 1 and
Layer 2 fixes from this issue (verified by inspecting the file list of every
commit in the chain, zero touches to `commands/`, `schemas.py`, or any
`api.py`):
- **No Marshmallow validator** on the `owners` field in
dashboard/chart/dataset schemas. `{"owners": [17, 17]}` would still be accepted
at the API and would surface later as a bare Postgres IntegrityError to the
client, instead of a clean 400.
- **No dedupe in `populate_owner_list`** (`superset/commands/utils.py`). Any
non-REST caller of that helper still forwards duplicates unchanged.
- **No change to the import path**
(`superset/connectors/sqla/models.py:701`, `self.owners = obj.get("owners",
[])`). Import of a YAML/ZIP with duplicate owner entries would fail hard on the
DB constraint mid-transaction, instead of validating and rejecting cleanly.
If maintainers want to build on the sc-107283 chain rather than start from
scratch, Layers 1 and 2 could be added on top as a small follow-up PR. Happy to
open the follow-up either way.
## 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.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]