Skip to content

fix(mcp): support custom user models during dashboard and chart operations - #45036

Open
FrancescoCastaldi wants to merge 14 commits into
apache:masterfrom
FrancescoCastaldi:fix/issue-45023-mcp-custom-user-model
Open

FrancescoCastaldi wants to merge 14 commits into
apache:masterfrom
FrancescoCastaldi:fix/issue-45023-mcp-custom-user-model

Conversation

@FrancescoCastaldi

Copy link
Copy Markdown
Contributor

SUMMARY

Fixes #45023.

When using a custom User model extended from Flask-AppBuilder, MCP chart creation and dashboard copy operations failed due to static references to User in SQLAlchemy event listeners and query filters, causing FlushError and unmapped table errors.

This change:

  1. Registers dashboard copy event listeners dynamically using security_manager.user_model.
  2. Isolates the database session in copy_dashboard to prevent SQLAlchemy FlushError when cloning dashboards with extended user relationships.
  3. Updates ExcludeUsersFilter in superset/security/manager.py to resolve username columns dynamically from security_manager.user_model.

TESTING INSTRUCTIONS

Run the dashboard unit tests:
pytest tests/unit_tests/models/dashboard_test.py

All tests pass, including unit tests verifying copy operations with custom extended user models.

ADDITIONAL INFORMATION

@netlify

netlify Bot commented Oct 6, 2026

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit fac344e
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6ac57770893a0e0008c134d6
😎 Deploy Preview https://deploy-preview-45036--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.

Comment thread superset/models/dashboard.py Outdated
Comment on lines 110 to 112
viewers=get_default_viewers_for_groups(
list(getattr(new_user, "groups", []) or [])
),

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 a user is inserted with groups assigned in memory, this reloaded user has no persisted group links yet, so the copied dashboard omits its group viewers.

Assessment: 🟠 Major · 🔁 Occurrence: Sometimes · 🏷️ Incorrect variable usage

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

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

**Path:** superset/models/dashboard.py
**Line:** 110:112
**Comment:**
	*Incorrect Variable Usage: When a user is inserted with groups assigned in memory, this reloaded user has no persisted group links yet, so the copied dashboard omits its group viewers.

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
👍 | 👎

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.

✅ CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved as of 1c9308e.

The dashboard copy now derives group viewers from target.groups directly instead of reloading the user and relying on persisted group links.

If that's not right, unresolve this thread and CodeAnt will leave it open.

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.

✅ CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved as of a857ad3.

Group viewers are now derived from the inserted target’s in-memory groups and rebound into the active session, rather than relying on reloaded group links.

If that's not right, unresolve this thread and CodeAnt will leave it open.

@codeant-ai-for-open-source

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

Copy link
Copy Markdown
Contributor

CodeAnt PR Risk: Low Risk

  • The PR appears safe to merge; the dashboard-copy change is limited to user creation and listener registration.
  • Group viewers are derived from the inserted user's in-memory groups, and subjects are rebound into the session used to create the dashboard.
  • Unit tests cover dynamic listener registration, missing templates, and copying dashboards with editor and viewer subjects.

Assessed commit: a857ad3f0674

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.66%. Comparing base (a5ffac5) to head (a857ad3).
⚠️ Report is 65 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #45036      +/-   ##
==========================================
+ Coverage   82.47%   82.66%   +0.18%     
==========================================
  Files        3002     3011       +9     
  Lines      186172   191723    +5551     
  Branches    43128    44399    +1271     
==========================================
+ Hits       153550   158492    +4942     
- Misses      29867    30200     +333     
- Partials     2755     3031     +276     
Flag Coverage Δ
hive 35.39% <43.47%> (-0.88%) ⬇️
mysql 53.79% <43.47%> (-1.44%) ⬇️
postgres 53.79% <43.47%> (-1.44%) ⬇️
presto 37.17% <43.47%> (-0.97%) ⬇️
python 86.47% <100.00%> (+0.14%) ⬆️
sqlite 53.54% <43.47%> (-1.42%) ⬇️
unit 80.08% <100.00%> (+0.70%) ⬆️

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.

@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 @FrancescoCastaldi, thanks for picking this one up, and good instinct on the isolated session. I can confirm the pre-existing session.commit() inside after_insert is broken on master for everyone, not just for custom user models: on a5ffac5 with DASHBOARD_TEMPLATE_ID set, sm.add_user(...) logs Error adding new user to database. This transaction is closed, returns False and writes no user at all, while on your branch the user is created and the copy lands. That is a real fix and it should go in.

I am holding it on three things in the implementation, all inline: two production branches that detect unittest.mock objects, and the isolated session not being able to hold the Subject objects that editors and viewers are built from, which only stays quiet right now because both lists come out empty.

One thing outside the diff that I would value your read on. #45023's traceback comes from superset/mcp_service/auth.py handing a base FAB User to Slice.last_saved_by, and on master that file no longer queries User itself: it delegates to SupersetSecurityManager.find_user_with_relationships (superset/mcp_service/auth.py:595), which queries self.user_model (superset/security/manager.py:5575). I wired up a custom security manager with a ProbeUser(User) model on ab_user and that call returned a ProbeUser, whereas on the 6.1.0 tag the same file did db.session.query(User) directly at line 163. So I suspect the reported FlushError was already fixed by the API key refactor in #39604, and that what you have found here is a separate (and real) bug in the template copy path. Might be worth checking with @lhpaoletti whether master still reproduces before sizing this PR around that issue. I could easily be missing a route that still yields a base User, so please push back if you see one.

All of the above was run locally against 95a5e0a in a throwaway venv. Full tests/unit_tests on your head: 3 failed, 21895 passed; on the merge base a5ffac5: 3 failed, 21889 passed, identical failed set, and all three are arm64 and babel environment failures unrelated to this PR. On top of that I copied the six new tests onto the base, and drove real inserts through sm.add_user and db.session.add on a sqlite app with ENABLE_VIEWERS and ASSIGN_CREATOR_GROUPS_AS_VIEWERS enabled. CI here is fully green (57 passing, 14 skipped), which is consistent with what I found: the test that would have caught the viewers regression is the one the sqla.inspect branch routes around.

Happy to dig into any of this with you, and if the session fix I sketched looks right I can share the exact patch I ran.

_copy_dashboard_for_user(target_session, target, dashboard_id)
return

with Session(bind=_connection) as session: # pylint: disable=disallowed-name

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.

This block worries me a bit. The Dashboard built just above goes into this new session, but its editors and viewers are Subject instances resolved on db.session: get_user_subject queries db.session (superset/subjects/utils.py:271) and subjects_from_groups does the same (superset/subjects/utils.py:410). A persistent object cannot be cascaded into a second session, so session.add(dashboard) plus session.flush() raises as soon as either list is non-empty.

It stays quiet on this branch only because both lists come back empty today (I measured editors=[] viewers=[] on a real insert). To check the path rather than the emptiness I left your code untouched and only made get_user_subject return a real db.session attached Subject, which is what it does whenever the user's subject row exists. sm.add_user(...) then fails:

ERROR:flask_appbuilder.security.sqla.manager:Error adding new user to database.
Object '<Subject at 0x...>' is already attached to session '3' (this is '5')

FAB swallows that and add_user returns False, so no user and no copy are written. The same thing happens if the viewers line is fixed to read the groups off the in-memory target, which is the fix for the bot's comment above, so closing that one uncovers this one.

Going back to the flushing session is not the way out either, I tried it and got InvalidRequestError: Session is already flushing swallowed the same way. What worked for me was keeping your isolated session and re-fetching the subjects inside it:

from superset.subjects.models import Subject

def _rebind(subjects: list[Subject | None]) -> list[Subject]:
    """The helpers resolve subjects on ``db.session``; a persistent object
    cannot be cascaded into a second session."""
    ids = [s.id for s in subjects if s is not None]
    return session.query(Subject).filter(Subject.id.in_(ids)).all() if ids else []

editors = _rebind([get_user_subject(target.id)])
viewers = _rebind(
    get_default_viewers_for_groups(list(getattr(target, "groups", []) or []))
)

With that, a user inserted carrying one group that has a Subject row writes cleanly and the copy comes out with viewers=[3], which is the behaviour tests/unit_tests/subjects/test_creator_group_call_sites.py::test_copy_dashboard_attaches_viewers_from_the_users_in_memory_groups exists to pin.

For the regression test I think it has to use a real session: insert a user with one group that has a Subject row, DASHBOARD_TEMPLATE_ID pointing at a real dashboard, then assert the copy's viewers is that group's subject. A Mock session cannot fail this way, which is why the new tests here pass.

Comment thread superset/models/dashboard.py Outdated
return

# Check if sqla.inspect is mocked (for compatibility with legacy tests)
if hasattr(sqla.inspect, "mock_calls"):

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.

This is the one I would most like to see go. It makes production behaviour depend on whether sqlalchemy.inspect happens to be a unittest.mock object, and what it keeps alive is tests/unit_tests/subjects/test_creator_group_call_sites.py::test_copy_dashboard_attaches_viewers_from_the_users_in_memory_groups. I checked by deleting just these seven lines: that test is the only one in the file that then fails.

The effect is that the one test asserting "viewers are resolved from the in-memory collection, never by querying membership" keeps running the old sqla.inspect(target).session path, while every real insert takes the Session(bind=_connection) path below, where new_user is a fresh DB load and its ab_user_group rows have not been written yet. So the invariant stays green in CI and stops holding at runtime. Measured on this branch with ENABLE_VIEWERS and ASSIGN_CREATOR_GROUPS_AS_VIEWERS on, a configured template, and a user inserted with one group that has a Subject row: the copy comes out viewers=[], where reading the groups off target gives viewers=[3].

Could we drop the branch and rewrite that test against the new structure? It patches dashboard_module.sqla.inspect and dashboard_module.Dashboard, neither of which the new path uses, so it needs updating either way.

Comment thread superset/security/manager.py Outdated
arg_name = "username"

@staticmethod
def _is_mock(obj: Any) -> bool:

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.

Same family as the sqla.inspect branch, and I would rather unittest.mock not be imported into the security manager at all. _get_username_column runs on the user list request path through SupersetUserApi.base_filters, so this makes a request path behave differently for tests.

I checked what it is protecting and it is three of the four tests in tests/unit_tests/security/exclude_users_filter_test.py, which patch superset.security.manager.current_app with a bare MagicMock(). With _is_mock short-circuited to False they fail with ArgumentError: SQL expression for WHERE/HAVING role expected, got <MagicMock name='mock.appbuilder.sm.user_model.username.not_in()'>. Adding one line, mock_sm.user_model = User, to each of those three makes all four pass with _is_mock and _get_username_column deleted outright. I ran it: 4 passed.

Which raises whether the ExcludeUsersFilter change belongs in this PR at all. For the model shape in #45023 (a subclass with __tablename__ = "ab_user") I could not find a behaviour difference: User.username.not_in([...]) and CustomUser.username.not_in([...]) both compile to (ab_user.username NOT IN ('x')). Dropping it would take 45 lines out of manager.py and keep this PR on the template copy bug, which is the part I think is genuinely broken.

return dash


def test_exclude_users_filter_with_custom_user_model(

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.

Just so you know, this one passes unchanged on the merge base (a5ffac5), so it is not pinning the ExcludeUsersFilter change. CustomUserModel.username and User.username resolve to the same ab_user.username column here, so filter_arg.left matches either way.

I copied all six new tests onto the base out of curiosity: four fail there and all four fail on ImportError: cannot import name 'register_dashboard_copy_events' or AttributeError: module 'superset.models.dashboard' does not have the attribute 'Session', which is the new symbols not existing yet rather than the bug reproducing. None of them drives a real session or a real alternative user model, which is why the cross-session problem I flagged on superset/models/dashboard.py:146 is invisible to them.

@FrancescoCastaldi

Copy link
Copy Markdown
Contributor Author

Thanks @gabotorresruiz for the thorough review and spot-on analysis.

Pushed commit 1c9308e addressing the feedback:

  1. Removed the sqla.inspect mock inspection branch in copy_dashboard.
  2. Implemented _rebind inside _copy_dashboard_for_user to re-fetch Subject instances within the isolated session (session.query(Subject).filter(Subject.id.in_(ids)).all()) and read groups directly from in-memory target.groups.
  3. Updated test_copy_dashboard_attaches_viewers_from_the_users_in_memory_groups to mock Session directly rather than sqla.inspect, and added a test covering subject re-binding in the isolated session.
  4. Dropped _is_mock and _get_username_column from manager.py, leaving ExcludeUsersFilter untouched.

All 46 unit tests in models/dashboard_test.py, subjects/test_creator_group_call_sites.py and security/exclude_users_filter_test.py pass cleanly.

@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.

Thanks @FrancescoCastaldi, this covers all three of the things I was holding on. The subjects are rebound inside the isolated session, both unittest.mock branches are gone, and superset/security/manager.py is out of the diff entirely. Approving so I am not in your way.

pre-commit is still red on two mypy errors, so it cannot merge as is. I left a suggestion for each, plus two non blocking notes.

Still curious whether #45023 itself reproduces on master after the API key refactor, but that is a question about the issue and not about this fix.

Comment thread superset/models/dashboard.py Outdated

session = sqla.inspect(target).session # pylint: disable=disallowed-name
new_user = session.query(User).filter_by(id=target.id).first()
def _rebind(subjects: list[Subject | None]) -> list[Subject]:

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.

pre-commit fails here: list is invariant, so the viewers call below cannot pass its list[Subject] into a list[Subject | None] parameter. mypy's own note suggests Sequence:

Suggested change
def _rebind(subjects: list[Subject | None]) -> list[Subject]:
def _rebind(subjects: Sequence[Subject | None]) -> list[Subject]:

with from collections.abc import Sequence added at the top of the module.

if not template:
return

editors = _rebind([get_user_subject(target.id)])

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.

Not a blocker, and it predates this PR: on a real insert this comes out [], because the user's Subject row is written after the user itself, so get_user_subject(target.id) finds nothing at after_insert time. Did you ever get a non empty editors here? If not, maybe worth a follow up issue rather than keeping the lookup.

MagicMock(),
MagicMock(),
SimpleNamespace(id=5), # type: ignore[arg-type]
user, # type: ignore[arg-type]

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.

The other mypy error: this ignore is unused now that copy_dashboard takes target: Any.

Suggested change
user, # type: ignore[arg-type]
user,

assert extra_attributes.user_id == 55


def test_copy_dashboard_rebinds_subjects_in_isolated_session(

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.

This one does pin _rebind: drop it from _copy_dashboard_for_user and this is the test that fails, so thank you for adding it.

Non blocking, but all six new tests fail on master only because Session and register_dashboard_copy_events do not exist there yet, so none of them would catch the cross-session bug coming back: a Mock session cannot raise is already attached to session. The shape that can is a real one, a Group with a Subject row, a user inserted carrying that group, DASHBOARD_TEMPLATE_ID pointing at a real dashboard, then assert the copy's viewers is that group's subject. Happy to share the harness I used if it helps.

@lhpaoletti

lhpaoletti commented Oct 8, 2026 •

Copy link
Copy Markdown

@gabotorresruiz I wanted to test it on master a couple of hours ago, but I might have run into migration issues regarding MCP-authentication, because it's not working right away with the OAuth configuration we have set up. I'll give it another go tomorrow, and if it works and is still relevant, I'll post it here. Anyone else is also welcome to test it!

@FrancescoCastaldi

Copy link
Copy Markdown
Contributor Author

Hi @gabotorresruiz,

Thanks for the suggestions! I have applied both fixes in commit a857ad3:

  1. Updated _rebind to accept Sequence[Subject | None] and imported Sequence from collections.abc in superset/models/dashboard.py to fix the invariance type error.
  2. Removed the unused # type: ignore[arg-type] comment in tests/unit_tests/subjects/test_creator_group_call_sites.py since copy_dashboard accepts target: Any.

All 42 unit tests in tests/unit_tests/subjects/test_creator_group_call_sites.py and tests/unit_tests/models/dashboard_test.py pass cleanly.

Regarding get_user_subject(target.id) at after_insert time: I agree with your observation that the user Subject row creation happens later in the lifecycle. Keeping the lookup safe here makes sense for now, and opening a dedicated follow-up issue to address the Subject timing and integration harness sounds like the right approach.

Thanks again for the thorough review and guidance!

Francesco

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP Chart Creation Fails With a Custom User Model

3 participants