Skip to content

fix(auth): durably revoke sessions on user delete/demote (survives restart)#106

Merged
GeiserX merged 2 commits into
mainfrom
fix/auth-revocation-durable
Jul 17, 2026
Merged

fix(auth): durably revoke sessions on user delete/demote (survives restart)#106
GeiserX merged 2 commits into
mainfrom
fix/auth-revocation-durable

Conversation

@GeiserX

@GeiserX GeiserX commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Closes the highest-severity finding from the security audit (CashPilot-rqw).

The bug

Deleting or demoting a user only bumped the in-memory auth._USER_PWD_EPOCH cache, and startup warm-up restored epochs solely from users.password_changed_at. So after any UI restart (webhook GitOps deploy, crash, reboot):

  • a deleted user's row is gone → warm-up can't restore the epoch → it resets to 0 → their still-valid 30-day signed cookie is accepted again;
  • a demoted user's password_changed_at was never touched → epoch not restored → their old cookie is accepted;

and because require_role reads the role from the token claim with no DB re-fetch, a removed/demoted owner regains owner (create users, deploy socket-privileged containers = host RCE, reveal the fleet key) for up to 30 days.

The fix (surgical; the fast no-DB-in-request-path guard is unchanged)

  • New session_revocations(user_id, revoked_before) table — deliberately no FK to users, so a deleted user's revocation outlives the row.
  • database.revoke_user_sessions() persists it (monotonic — an older timestamp can't lower an existing revocation; prunes rows whose 30-day window has elapsed, since those tokens are already expired) + list_session_revocations() reads them back.
  • The delete/demote routes now persist the revocation durably in addition to the in-memory epoch bump (immediate effect + restart-proof).
  • Startup warm-up (factored into _warm_session_epochs()) restores the epoch cache from both password_changed_at and session_revocations, taking the later timestamp per user. Freshly-minted tokens (issued at login with the current DB role) are unaffected — a demoted user simply re-logs in and gets their new role.

Why revocation-epoch rather than DB role re-fetch: it keeps the deliberate "no DB call in the request path" design (an explicit comment in decode_session_token); the demoted/deleted user's old token is rejected outright, so the stale role in the token is never honored.

Verification

ruff check . + ruff format --check . clean; 1149 tests pass. New tests/test_session_revocation.py: durable store (persist / monotonic / prune / survives user deletion) + the restart restore (revoke → clear cache → warm-up → old cookie rejected, fresh cookie accepted with the new role) + password/revocation merge.

Forward-only schema (CREATE TABLE IF NOT EXISTS in _SCHEMA) — existing DBs get the table on next start. Not auto-merging: this touches the session-auth path and adds a table, so flagging for your review.

Summary by CodeRabbit

  • Security

    • Session cookies are now persistently revoked when an account is deleted or demoted, not just in memory.
    • Revocations survive restarts by being restored into the session-epoch cache during startup warm-up.
    • Session invalidation now merges revocation timing with password-change updates for correct precedence.
  • Bug Fixes

    • Fixed cases where prior session revocations could be re-accepted after restart.
    • Expired revocation records are automatically pruned over time.
  • Tests

    • Added regression coverage for durable revocation storage and startup warm-up behavior.

…start)

Closes the highest-severity audit finding (CashPilot-rqw). Deleting or demoting a
user only bumped the in-memory auth._USER_PWD_EPOCH cache; startup warm-up restored
epochs solely from users.password_changed_at. So after any UI restart (webhook
GitOps deploy, crash, reboot) the epoch reset to 0 and the account's still-valid
30-day signed cookie was honored again — a deleted owner regained owner, a demoted
owner kept owner — because the role is read from the token claim with no DB re-fetch.

Fix (keeps the fast no-DB-in-request-path guard unchanged):
- New session_revocations table (user_id, revoked_before). DELIBERATELY no FK to
  users, so a deleted user's revocation outlives the row.
- database.revoke_user_sessions() persists the revocation (monotonic — never lowers
  an existing one; prunes rows whose 30-day window has fully elapsed) and
  list_session_revocations() reads them back.
- delete/demote routes now persist the revocation durably in addition to the
  in-memory epoch bump.
- Startup warm-up (_warm_session_epochs, factored out + testable) restores the
  epoch cache from BOTH password_changed_at and session_revocations, taking the
  later timestamp per user — so revocations survive restarts. Freshly-minted
  tokens (issued at login with the current DB role) are unaffected.

Tests: durable store (persist/monotonic/prune/survives-deletion) + the restart
restore (revoke -> clear cache -> warm-up -> old cookie rejected, fresh accepted)
+ password/revocation merge. Full suite 1149 pass, ruff clean.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 782e6d5b-afaf-427d-b865-606217448fb1

📥 Commits

Reviewing files that changed from the base of the PR and between 54c6318 and a1be93d.

📒 Files selected for processing (2)
  • app/routers/users.py
  • tests/test_session_revocation.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/routers/users.py
  • tests/test_session_revocation.py

📝 Walkthrough

Walkthrough

Durable per-user session revocation is stored in SQLite, applied during role changes and user deletion, restored into the auth epoch cache at startup, and covered by persistence, restart, merging, and route tests.

Changes

Session revocation

Layer / File(s) Summary
Revocation storage and persistence tests
app/database.py, tests/test_session_revocation.py
Adds the session_revocations table and database helpers for monotonic persistence, listing, and expiry pruning, with regression coverage.
User role and deletion revocation
app/routers/users.py, tests/test_main_routes.py
Role updates and user deletion persist revocations before updating the in-memory session epoch.
Startup epoch restoration and coverage
app/main.py, tests/test_main_routes.py, tests/test_session_revocation.py, CHANGELOG.md
Startup merges password and durable revocation epochs, restores the latest value, and documents the behavior with restart coverage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • GeiserX/CashPilot#98: Updates role-change and user-deletion flows to invalidate existing sessions through the auth session epoch.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: durable session revocation for user delete/demote that persists across restarts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-revocation-durable

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.55%. Comparing base (2da01e2) to head (a1be93d).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #106      +/-   ##
==========================================
+ Coverage   93.04%   93.55%   +0.51%     
==========================================
  Files          31       31              
  Lines        3276     3303      +27     
==========================================
+ Hits         3048     3090      +42     
+ Misses        228      213      -15     
Files with missing lines Coverage Δ
app/database.py 91.17% <100.00%> (+2.82%) ⬆️
app/main.py 94.32% <100.00%> (+0.31%) ⬆️
app/routers/users.py 100.00% <100.00%> (ø)
🚀 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/test_main_routes.py (1)

1042-1051: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the durable revocation calls.

These tests only patch the new calls, so they do not fail if route integration regresses.

  • tests/test_main_routes.py#L1042-L1051: bind the mock and assert one awaited role-update revocation.
  • tests/test_main_routes.py#L1086-L1095: bind the mock and assert one awaited deletion revocation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_main_routes.py` around lines 1042 - 1051, Bind the
revoke_user_sessions mock in test_api_update_user_role and assert it was awaited
exactly once after the PATCH request. In tests/test_main_routes.py lines
1086-1095, bind the deletion-revocation mock and assert it was awaited exactly
once as well, preserving the existing response assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/routers/users.py`:
- Around line 52-54: The user mutation and session revocation must commit
atomically in both affected routes: in app/routers/users.py lines 52-54, combine
the role update with the revocation upsert; in lines 73-75, combine the
revocation upsert with user deletion. Update the in-memory epoch only after the
transaction commits, and add appropriate error handling and logging for
transaction failures.

In `@tests/test_session_revocation.py`:
- Around line 79-88: Update test_survives_user_deletion so run creates user 42
before calling revoke_user_sessions and delete_user. Ensure the created user is
actually deleted, then retain the assertion that user ID 42 remains in
list_session_revocations.

---

Outside diff comments:
In `@tests/test_main_routes.py`:
- Around line 1042-1051: Bind the revoke_user_sessions mock in
test_api_update_user_role and assert it was awaited exactly once after the PATCH
request. In tests/test_main_routes.py lines 1086-1095, bind the
deletion-revocation mock and assert it was awaited exactly once as well,
preserving the existing response assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: eef9b4ad-9a07-4e87-bbbb-70f86ecaeb31

📥 Commits

Reviewing files that changed from the base of the PR and between 2da01e2 and 54c6318.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • app/database.py
  • app/main.py
  • app/routers/users.py
  • tests/test_main_routes.py
  • tests/test_session_revocation.py

Comment thread app/routers/users.py
Comment on lines +52 to +54
revoked_at = time.time()
await main.database.revoke_user_sessions(user_id, revoked_at)
main.auth.set_user_pwd_epoch(user_id, revoked_at)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Make user mutation and session revocation one transaction.

Both routes commit the user mutation before durable revocation, allowing partial failures to leave old privileged cookies valid.

  • app/routers/users.py#L52-L54: atomically update the role and upsert the revocation.
  • app/routers/users.py#L73-L75: atomically upsert the revocation and delete the user.

After the transaction commits, update the in-memory epoch.

As per path instructions, “Check for proper error handling and logging.”

📍 Affects 1 file
  • app/routers/users.py#L52-L54 (this comment)
  • app/routers/users.py#L73-L75
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/routers/users.py` around lines 52 - 54, The user mutation and session
revocation must commit atomically in both affected routes: in
app/routers/users.py lines 52-54, combine the role update with the revocation
upsert; in lines 73-75, combine the revocation upsert with user deletion. Update
the in-memory epoch only after the transaction commits, and add appropriate
error handling and logging for transaction failures.

Source: Path instructions

Comment thread tests/test_session_revocation.py
@GeiserX

GeiserX commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…e window)

Address CodeRabbit review on #106:
- delete/demote now persist the session revocation BEFORE deleting/updating the
  user row, so a crash between the two writes can't leave the account
  mutated-but-not-revoked (old privileged cookie still valid). Revocation is
  keyed by uid with no FK, so persisting it first is valid.
- test_survives_user_deletion now creates a real user first, so it genuinely
  proves the revocation is not dropped when a real users row is deleted (would
  catch a future cascading FK).
@GeiserX

GeiserX commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

Thanks @coderabbitai — both addressed in the latest commit:

  1. Mutation/revocation ordering (Major) — reordered both routes to persist the session revocation before the user mutation (revoke_user_sessions → then update_user_role/delete_user). Since the revocation is keyed by uid and has no FK to users, persisting it first is valid, and it eliminates the window where the mutation has committed but the old privileged cookie is still honored — a failure now leaves the account revoked-but-not-mutated (safe: logged out, re-login re-mints with the current role), never mutated-but-not-revoked. Chose reorder over a shared transaction because the two helpers each borrow the pooled connection and commit independently; revoke-first gives the same safety guarantee more simply.
  2. Deletion-survival test (Minor) — the test now create_users a real row before deleting it, so it genuinely verifies session_revocations is not dropped with the user (and would catch a future cascading FK).

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline app/routers/users.py --items all --type function
ast-grep outline tests/test_session_revocation.py --items all --type class,function

printf '\n--- session-revocation calls in user routes ---\n'
rg -n -C 14 'revoke_user_sessions|update_user_role|delete_user' app/routers/users.py

printf '\n--- deletion-survival test ---\n'
rg -n -i -C 18 'delet.*surviv|surviv.*delet|create_user|delete_user' tests/test_session_revocation.py

Length of output: 8182


@GeiserX Verified from the latest code:

  • Both routes durably revoke sessions before mutating or deleting the user.
  • The deletion-survival test creates a real user, deletes it, and confirms its revocation remains.

🐇 ✅

@GeiserX
GeiserX merged commit be3bbcb into main Jul 17, 2026
8 checks passed
@GeiserX
GeiserX deleted the fix/auth-revocation-durable branch July 17, 2026 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant