Skip to content

feat(security): terminate active sessions when an account is disabled#40695

Open
rusackas wants to merge 9 commits into
masterfrom
feat/session-invalidation-epoch
Open

feat(security): terminate active sessions when an account is disabled#40695
rusackas wants to merge 9 commits into
masterfrom
feat/session-invalidation-epoch

Conversation

@rusackas
Copy link
Copy Markdown
Member

@rusackas rusackas commented Jun 3, 2026

SUMMARY

Implements Part A2 of the session/token-lifecycle SIP (#40674) as a self-contained, tested change: a backend-agnostic per-user session-invalidation epoch that terminates a disabled account's outstanding sessions on their next request.

Previously, disabling a user only audit-logged; access lingered until a passive check, and for client-side cookie sessions there was no server-side session to delete at all.

How it works

  • UserAttribute.sessions_invalidated_at (new column + migration) records the invalidation epoch.
  • on_user_login stamps session["_login_at"].
  • A before_request hook forces logout of any session whose login predates the user's epoch, then lets the request continue as anonymous so each route responds correctly for its type (401 for the REST API, redirect-to-login for HTML views) — no route-kind branching needed. Fails open on any error (never locks everyone out on a bug).
  • A SQLAlchemy after_update listener stamps the epoch when active flips to False, so it fires regardless of the disable path (admin UI, REST API, or CLI) and for both client-side cookie and server-side session backends. Deleted users are already rejected by Flask-Login's loader, so deletion needs no epoch.

Backwards compatible by default: inert for users that were never disabled (NULL epoch). The naive-UTC column comparison is handled explicitly.

Closes part of #40674 (A2). A1/A3/Part B remain in the SIP.

TESTING INSTRUCTIONS

pytest tests/unit_tests/security/test_session_invalidation.py
pytest tests/integration_tests/security/session_invalidation_tests.py

Validated end-to-end against a local Docker stack:

  • Migration applies cleanly to Postgres.
  • Disabling a user via the ORM fires the event and stamps the epoch (incl. the INSERT-upsert path for users with no attribute row).
  • Full HTTP flow: login → /api/v1/me/ returns 200 → admin disables the user → same session → /api/v1/me/ returns 401.

Unit tests cover the epoch comparison incl. the UTC/naive-datetime correctness; the integration test covers the login → disable → forced-logout flow and the "active user is unaffected" case.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API
  • Removes existing feature or API

🤖 Generated with Claude Code

@dosubot dosubot Bot added authentication Related to authentication change:backend Requires changing the backend labels Jun 3, 2026
@bito-code-review
Copy link
Copy Markdown
Contributor

bito-code-review Bot commented Jun 3, 2026

Code Review Agent Run #ca6ef4

Actionable Suggestions - 0
Review Details
  • Files reviewed - 7 · Commit Range: 44762aa..44762aa
    • superset/initialization/__init__.py
    • superset/migrations/versions/2026-06-02_10-00_f7a1c93e0b21_add_sessions_invalidated_at.py
    • superset/models/user_attributes.py
    • superset/security/manager.py
    • superset/security/session_invalidation.py
    • tests/integration_tests/security/session_invalidation_tests.py
    • tests/unit_tests/security/test_session_invalidation.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@rusackas rusackas requested review from dpgaspar and hainenber June 3, 2026 00:56
@github-actions github-actions Bot added the risk:db-migration PRs that require a DB migration label Jun 3, 2026
Comment thread superset/security/session_invalidation.py Outdated
@bito-code-review
Copy link
Copy Markdown
Contributor

The flagged issue regarding a race condition in the invalidate_user_sessions function is correct. The current implementation performs a non-atomic update-then-insert, which can lead to duplicate UserAttribute rows if two concurrent processes attempt to disable the same user simultaneously.

To resolve this, you can use a database-level upsert. Since Superset supports multiple database backends, the most robust approach is to use SQLAlchemy's dialects to handle ON CONFLICT (for PostgreSQL/SQLite) or to serialize the operation using a SELECT ... FOR UPDATE lock.

Given the context, a simple and effective fix is to use a SELECT ... FOR UPDATE lock to serialize the check-and-insert operation:

def invalidate_user_sessions(connection: Any, user_id: int) -> None:
    from superset.models.user_attributes import UserAttribute
    table = UserAttribute.__table__
    now = _utcnow().replace(tzinfo=None)
    
    # Serialize the check-and-insert using a row-level lock
    with connection.begin_nested():
        existing = connection.execute(
            table.select().where(table.c.user_id == user_id).with_for_update()
        ).fetchone()
        
        if existing:
            connection.execute(
                table.update().where(table.c.user_id == user_id).values(sessions_invalidated_at=now, changed_on=now)
            )
        else:
            connection.execute(
                table.insert().values(user_id=user_id, sessions_invalidated_at=now, created_on=now, changed_on=now)
            )

Would you like me to check the other comments on this PR and implement fixes for them as well?

superset/security/session_invalidation.py

# Serialize the check-and-insert using a row-level lock
    with connection.begin_nested():
        existing = connection.execute(
            table.select().where(table.c.user_id == user_id).with_for_update()
        ).fetchone()
        
        if existing:
            connection.execute(
                table.update().where(table.c.user_id == user_id).values(sessions_invalidated_at=now, changed_on=now)
            )
        else:
            connection.execute(
                table.insert().values(user_id=user_id, sessions_invalidated_at=now, created_on=now, changed_on=now)
            )

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 3, 2026

Codecov Report

❌ Patch coverage is 86.25954% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.05%. Comparing base (c505c70) to head (9ec28e3).
⚠️ Report is 44 commits behind head on master.

Files with missing lines Patch % Lines
superset/security/session_invalidation.py 80.82% 7 Missing and 7 partials ⚠️
superset/security/manager.py 85.18% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #40695      +/-   ##
==========================================
+ Coverage   64.04%   64.05%   +0.01%     
==========================================
  Files        2663     2665       +2     
  Lines      143633   144044     +411     
  Branches    33036    33112      +76     
==========================================
+ Hits        91988    92272     +284     
- Misses      50043    50151     +108     
- Partials     1602     1621      +19     
Flag Coverage Δ
hive 39.77% <51.14%> (+0.10%) ⬆️
mysql 58.47% <86.25%> (+0.07%) ⬆️
postgres 58.54% <86.25%> (+0.07%) ⬆️
presto 41.36% <51.14%> (+0.09%) ⬆️
python 60.00% <86.25%> (+0.05%) ⬆️
sqlite 58.16% <86.25%> (+0.07%) ⬆️
unit 100.00% <ø> (ø)

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.

@bito-code-review
Copy link
Copy Markdown
Contributor

bito-code-review Bot commented Jun 3, 2026

Code Review Agent Run #09c6a4

Actionable Suggestions - 0
Review Details
  • Files reviewed - 5 · Commit Range: 44762aa..d9401fb
    • superset/migrations/versions/2026-06-02_10-00_f7a1c93e0b21_add_sessions_invalidated_at.py
    • superset/models/user_attributes.py
    • superset/security/session_invalidation.py
    • tests/integration_tests/security/session_invalidation_tests.py
    • tests/unit_tests/security/test_session_invalidation.py
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@pull-request-size pull-request-size Bot added size/XL and removed size/L labels Jun 4, 2026
@github-actions github-actions Bot added the api Related to the REST API label Jun 4, 2026
@netlify
Copy link
Copy Markdown

netlify Bot commented Jun 4, 2026

Deploy Preview for superset-docs-preview ready!

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

claude and others added 4 commits June 4, 2026 11:12
Implements Part A2 of the session/token-lifecycle SIP (#40674): a
backend-agnostic per-user invalidation epoch.

- `UserAttribute.sessions_invalidated_at` (migration) records when a user's
  sessions were invalidated.
- Login stamps `session["_login_at"]`; a `before_request` hook forces logout of
  any session that predates the user's epoch, then lets the request continue as
  anonymous so each route responds correctly for its type (401 for the REST API,
  redirect-to-login for HTML views).
- A SQLAlchemy `after_update` listener stamps the epoch when `active` flips to
  False, so it fires regardless of the disable path (admin UI, REST API, CLI),
  for both client-side cookie and server-side session backends.

Inert for users that were never disabled (NULL epoch) — backwards compatible by
default. Comparison treats the naive UTC column correctly. Validated end-to-end
against a local Docker stack (login -> disable -> forced 401) plus unit and
integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… upsert

Add a unique constraint on user_attribute.user_id (de-duping any existing
rows first) and make invalidate_user_sessions retry as an update when a
concurrent disable wins the insert race, preventing duplicate attribute rows.
Also picks up pre-commit auto-fixes (auto-walrus, ruff-format).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SQLite has no ALTER ... ADD/DROP CONSTRAINT, so wrap the
user_attribute.user_id unique constraint create/drop in batch_alter_table.
Also picks up a ruff-format normalization in the unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
@rusackas rusackas force-pushed the feat/session-invalidation-epoch branch from 2e58d95 to 1244f59 Compare June 4, 2026 18:15
# ``extra_attributes[0]``). Enforce that invariant so the session-invalidation
# upsert is race-safe. Drop any pre-existing duplicates, keeping the lowest
# ``id`` per user, before adding the unique constraint.
op.execute(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This drops avatar_url / welcome_dashboard_id from any non-MIN(id) row without merging them into the kept row. Operators upgrading from deployments with duplicates will silently lose those settings. Shouldn't we merge non-NULL fields first, or fail the migration with operator instructions for the duplicate-row case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in fe314e5. Before deleting duplicates the migration now backfills the kept (MIN id) row from any higher-id sibling: for each of avatar_url, welcome_dashboard_id and sessions_invalidated_at, a kept row that is NULL takes the lowest-id non-NULL value from its duplicates. So a setting that lived only on a non-MIN row is merged forward rather than dropped. The subqueries are plain correlated SQL so they run on SQLite/MySQL/Postgres alike.



def upgrade():
add_columns(TABLE, sa.Column(COLUMN, sa.DateTime(), nullable=True))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If an account is disabled today and re-enabled after this lands, the old session has no _login_at and no epoch. is_session_invalidated(None, None) returns False and the stale session revives. UPDATING.md says re-enabling starts a fresh session, but the code doesn't enforce that for pre-existing disabled accounts. We should backfill sessions_invalidated_at for ab_user.active=false users here, or stamp an epoch on re-enable when none exists.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in 9ec28e3. The migration now backfills sessions_invalidated_at for any account that is already disabled (ab_user.active is false) at upgrade time, upserting one user_attribute row per such user. That way re-enabling a previously-disabled account does not revive a pre-existing session (which carries no recorded login time), so the behavior matches what UPDATING.md describes for accounts disabled before this lands as well.

assert attribute.sessions_invalidated_at is not None

# ...so the previously-authenticated session is now forced out.
assert self.client.get("/api/v1/me/").status_code != 200
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we use == 401 here? != 200 means the test would pass with 500 or 403. If we wanted to have more status codes we accept, we could maybe compare the status_code to an array of status codes we accept?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed — tightened it to == 401. The enforce_session_validity hook clears the session and continues anonymous, so the protected REST route (/api/v1/me/) answers 401 specifically; asserting the exact code catches a regression that turned a forced-logout into an unrelated 500/403. Fixed in 29e0ded.

…recision

MySQL DATETIME columns store no fractional seconds and truncate the
stored instant. A guest token or session minted in the same wall-clock
second as a revoke/disable carried a timestamp equal to the truncated
epoch and survived the strict `<` comparison, leaving an outstanding
token/session unrevoked. Round the stored epoch up to the next whole
second so it strictly exceeds any same-second issuance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@bito-code-review bito-code-review Bot left a comment

Choose a reason for hiding this comment

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

Code Review Agent Run #c3b63e

Actionable Suggestions - 1
  • superset/security/manager.py - 1
Additional Suggestions - 1
  • superset/dashboards/api.py - 1
    • Missing unit tests for revoke_embedded · Line 2160-2209
      The new `revoke_embedded` endpoint lacks functional tests. BITO.md rule [11730] requires unit tests covering success paths, error scenarios, and edge cases for new tools. Currently only the permission name is verified in `test_info_security_dashboard` (line 729), but no test validates actual revocation behavior or access control for this endpoint.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/migrations/versions/2026-06-02_10-00_f7a1c93e0b21_add_sessions_invalidated_at.py - 1
    • MIGRATION: Column already exists in model · Line 46-46
Review Details
  • Files reviewed - 13 · Commit Range: 9814382..09aeec7
    • superset/daos/dashboard.py
    • superset/dashboards/api.py
    • superset/initialization/__init__.py
    • superset/migrations/versions/2026-06-02_10-00_f7a1c93e0b21_add_sessions_invalidated_at.py
    • superset/migrations/versions/2026-06-02_12-00_c4d5e6f7a8b9_add_guest_token_revoked_before.py
    • superset/models/embedded_dashboard.py
    • superset/models/user_attributes.py
    • superset/security/manager.py
    • superset/security/session_invalidation.py
    • tests/integration_tests/dashboards/api_tests.py
    • tests/integration_tests/security/guest_token_revocation_tests.py
    • tests/integration_tests/security/session_invalidation_tests.py
    • tests/unit_tests/security/test_session_invalidation.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment thread superset/security/manager.py
rusackas and others added 4 commits June 5, 2026 10:24
…s check

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preserve avatar_url / welcome_dashboard_id / sessions_invalidated_at that
exist only on a higher-id duplicate row by backfilling the kept (MIN id)
row before deleting the duplicates, so operators upgrading from deployments
with duplicate rows do not silently lose those settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MySQL rejects referencing the UPDATE/DELETE target table in a subquery
(error 1093), which broke the correlated-subquery backfill on test-mysql.
Read the duplicate rows and resolve the merge winner in Python instead;
this behaves identically on SQLite, MySQL and Postgres.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accounts disabled before this feature lands had no epoch, so re-enabling
one could revive a pre-existing session (which also carries no recorded
login time). The migration now stamps sessions_invalidated_at for users
whose ab_user.active is false, upserting one row per user. Done in Python
to stay portable across SQLite/MySQL/Postgres.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bito-code-review
Copy link
Copy Markdown
Contributor

bito-code-review Bot commented Jun 5, 2026

Code Review Agent Run #b0775f

Actionable Suggestions - 0
Review Details
  • Files reviewed - 3 · Commit Range: 09aeec7..9ec28e3
    • tests/integration_tests/security/session_invalidation_tests.py
    • tests/unit_tests/security/audit_log_test.py
    • superset/migrations/versions/2026-06-02_10-00_f7a1c93e0b21_add_sessions_invalidated_at.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

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 authentication Related to authentication change:backend Requires changing the backend risk:db-migration PRs that require a DB migration size/XL

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

3 participants