Skip to content

Keep sessions in step with user account changes - #626

Open
badbread wants to merge 2 commits into
mainfrom
fix/session-lifecycle
Open

Keep sessions in step with user account changes#626
badbread wants to merge 2 commits into
mainfrom
fix/session-lifecycle

Conversation

@badbread

@badbread badbread commented Sep 7, 2026

Copy link
Copy Markdown
Owner

What changed

A signed-in device could outlive the account behind it.

The auth extractor only asked whether a session id had been revoked.
sessions.user_id is ON DELETE CASCADE, so removing a user made that user's
session rows disappear rather than be flagged: the removed account's token read
as "not revoked" and kept working for the rest of its expiry, which for a
remembered mobile login is up to ten years. Editing a user changed nothing for
tokens already handed out, either, and a user's extra camera grants were baked
into the token at login, so taking a camera back only took effect at that
person's next sign-in.

Sessions are now resolved positively:

  • Each session id is looked up against sessions once and cached in
    AppState, so a missing or revoked row is refused (401) and the warm path
    still costs no database round trip. An unknown id is resolved on the spot
    rather than assumed live or dead, so a token minted a moment ago (here or on
    another replica) is never wrongly rejected. A database failure surfaces as a
    5xx, never a 401, so a transient blip cannot read to a client as "signed
    out".
  • The same lookup returns the owning user's per-user camera grants. The
    extractor reads those from the user row instead of from claims.camera_ids,
    keeping the existing union with the role's cameras exactly as it was. Adding
    or withdrawing a grant now applies on that user's next request.
  • A login token with no session id is refused. The sessions table
    predates the first public release (v0.0.1, 2026-07-06), so no released client
    holds such a token, and one could never be signed out.
  • Removing a user revokes their sessions before the row goes.
  • Setting a new password, or changing the assigned role, ends that user's
    sessions. The exception is an admin editing their own account: the
    session the request came in on is kept, so changing your own password does
    not eject you from the console mid-edit. A save that changes nothing signs
    nobody out. Changing only the extra-cameras list does not end sessions
    either, because that list is read per request and so applies immediately
    anyway.

The admin console's user editor gains a Sign out everywhere button over the
existing DELETE /auth/users/:id/sessions endpoint, with a confirm that says
when it will sign you out of the console too, and a toast reporting how many
sessions ended.

Scoped media tokens are untouched. They are a different token type with no
session of their own, resolved before any of this, and a regression test pins
that.

Why

Token lifetimes here are long by design (the mobile "keep me signed in" option),
so "the account changed" has to reach credentials that were already issued.
Everything above is about the server's answer staying current with the user
table rather than with a snapshot taken at login.

Testing

New suite services/api/tests/session_lifecycle.rs (real router, real
extractor, real Postgres) covering: a removed user's token is 401 on a scoped
route; a password change ends the old session and the new password works; a
role change ends it; an extra-cameras change is reflected on the next request
without re-login, in both the grant and the withdraw direction, with the role's
own cameras unaffected; a no-op save keeps the session; a self-edit keeps the
acting session and ends the admin's other device; a token with no session id is
401; a token whose session row never existed is 401; a scoped media token still
serves segment bytes.

Gate run on the build box against this branch: cargo fmt --all -- --check,
cargo clippy --all-targets -- -D warnings, and cargo test --workspace
against a throwaway Postgres, all green. node --check on the extracted
admin.html script block passes.

Operator-visible

  • No new environment keys, no changed defaults, no schema change (comments in
    db/migrations/0033_sessions.sql were brought up to date; the SQL is
    untouched).
  • Users are signed out on their other devices when an admin sets their password
    or changes their role, and lose access immediately when their account is
    removed. Every client already handles a 401 by returning to its sign-in
    screen, so no client change was needed.
  • Docs: docs-site/docs/admin-console/users-and-access.md gains a short
    "Signing devices out" section. Design recorded in docs/DECISIONS.md
    (2026-09-07) and a new row in docs/COMPONENT-MAP.md section G.

Gate output: GATE_OK fix/session-lifecycle (exit 0) on commit 5b3f6a6.

A signed-in device could outlive the account behind it. The auth extractor
only asked whether a session id had been REVOKED, and `sessions.user_id` is
`ON DELETE CASCADE`, so removing a user made the rows disappear rather than be
flagged: the removed account's token read as "not revoked" and kept working for
the rest of its expiry, which for a remembered mobile login is years. Editing a
user changed nothing for tokens already handed out either, and a user's extra
camera grants were baked into the token at login, so taking a camera away only
took effect at that person's next sign-in.

Sessions are now resolved positively. Each session id is looked up against the
`sessions` table once and cached in `AppState`, so a missing or revoked row is
refused and the warm path still costs no database round trip; an unknown id is
resolved on the spot rather than assumed either way, so a token minted a moment
ago is never wrongly rejected. The same lookup returns the owning user's camera
grants, which the extractor now reads from the user row instead of the token,
keeping the existing union with the role's cameras. A login token with no
session id is refused: the sessions table predates the first public release, so
no released client holds one, and such a token could never be signed out.

Removing a user ends their sessions before the row goes. Setting a new password
or changing the assigned role ends that user's sessions too, except the one the
request came in on when an admin is editing their own account, so changing your
own password does not eject you from the console. A save that changes nothing
signs nobody out, and changing only the extra-cameras list does not either: that
list is now read per request, so it applies immediately anyway.

The admin console's user editor gains a "Sign out everywhere" button over the
existing admin revoke endpoint, with a confirm that says when it will sign you
out of the console as well.

Scoped media tokens are untouched: they are a different token type with no
session of their own and are resolved before any of this.

Adds `services/api/tests/session_lifecycle.rs` covering each case, updates the
operator page on users and access, and records the design in docs/DECISIONS.md
and docs/COMPONENT-MAP.md.

Signed-off-by: badbread <badbread@users.noreply.github.com>
Signed-off-by: badbread <badbread@users.noreply.github.com>
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