Skip to content

Fix/session and device management#67

Merged
ademboukabes merged 19 commits into
developfrom
fix/session-and-device-management
Jul 18, 2026
Merged

Fix/session and device management#67
ademboukabes merged 19 commits into
developfrom
fix/session-and-device-management

Conversation

@Tyjfre-j

@Tyjfre-j Tyjfre-j commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

PR: Phase 1 & 2 — Schema Migration + Core Service Layer Rewrite

What this PR does

Implements the first two phases of the Session & Device Management rebuild. The original bug was: device ownership was exclusive (one device row = one user globally), causing reassignment conflicts when the same physical device logged in as a different user. This PR fixes the ownership model and cleans up the device/session lookup path.


Phase 1 — Schema migration

  • user_devices.physical_device_id — new UUID column, NOT NULL, with unique index (user_id, physical_device_id). Device ownership is now a composite key: the same physical device can legitimately belong to multiple users.
  • user_devices.id — kept as surrogate PK. user_sessions.device_id FK still points here; no cascade migration needed.
  • refresh_tokens table — new table with session_id (FK, cascade delete), family_id, token_hash (unique), used, used_at. Populated in Phase 3.
  • SQL queries — added GetDeviceByPhysicalId (scoped), GetAnyDeviceByPhysicalId (unscoped, :many). Updated CreateDevice to include physical_device_id.
  • sqlc generate — regenerated from updated schema.

Phase 2 — Core service layer rewrite

  • _ensure_device_for_login — completely rewritten:
    • Looks up device by (user_id, physical_device_id) instead of unscoped get_device_by_id_any
    • Removed "device belongs to another user" branch — structurally impossible now
    • No device cap enforced (unlimited devices per user; only sessions are capped)
    • Creates new device row if not found
  • Fixed FK bug in _create_mobile_session — was using req.device_id (physical ID) directly as user_sessions.device_id (surrogate PK). Now correctly captures the returned device.id from _ensure_device_for_login.
  • Fixed session-cap bug — cap check was running before knowing if login was a replace or new session. Now checks get_session_by_device_for_user first; cap only applies to genuinely new sessions.
  • Renamed MobileAuthBaseRequest.device_idphysical_device_id — makes the API contract unambiguous. Surrogate device_id is only used server-side (revoke, token update, inactivate, session list).
  • Deleted dead create_device in DeviceService — had its own hardcoded 3-device cap and was missing physical_device_id; confirmed unused via grep.

Design decisions

Decision Choice Why
Device cap None — unlimited devices Only sessions are capped at 3; device rows are just bookkeeping
Same-device re-login Replace existing session UNIQUE(user_id, device_id) on sessions + mobile-only app = no legitimate multi-session-per-device case; self-heals orphaned sessions
Atomicity Upsert (INSERT ... ON CONFLICT ... DO UPDATE) Prevents race on concurrent logins from same device; session id stays stable

Testing

  • Unit tests — 95 passed
  • E2E tests — 30 passed (requires MULTAI_RUN_E2E=1 and running server + workers)
  • Integration tests — 3 passed
  • Security tests — 4 passed
  • Worker tests — 12 passed
  • Total: 144 passed, 0 failed

Checklist

  • Schema migrations applied and verified live
  • sqlc generate produces clean output
  • ruff check . clean
  • mypy clean
  • All unit tests pass
  • All E2E tests pass
  • No breaking changes to non-auth endpoints
  • Surrogate PK preserved for backward compatibility

Tyjfre-j and others added 19 commits July 17, 2026 00:56
@Tyjfre-j

Copy link
Copy Markdown
Collaborator Author

Before these changes:

  • Session cap at 3/3 hard-rejected new logins with 403.
  • last_active was only set at login/replace time — never updated during ongoing use.
  • Eviction, if it existed, would have been "oldest login" rather than true LRU.

After these changes:

  • LRU eviction: At cap, the least-recently-used session is evicted and the new login succeeds.
  • Ongoing activity tracking: last_active is kept reasonably fresh via throttled writes (60s default, configurable) without defeating the "0 DB queries on the fast path" design.
  • Deterministic tiebreak: (last_active, created_at) sort key handles concurrent logins in the same second.

Architecture: how we track session recency

Options considered

Approach Verdict Why
Write last_active to Postgres on every fast-path hit ❌ Rejected Defeats the "0 DB queries" design outright
Fire-and-forget the write (unawaited coroutine) ❌ Rejected Request's DB connection is tied to get_db()'s engine.begin() lifecycle; unawaited write risks running against a closing connection. Needs a separate connection — more machinery than justified
Track recency in Redis only, decoupled from Postgres ❌ Rejected /me's session list needs durable, queryable last_active via SQL. A second, ephemeral source of truth for the same concept is more complexity, not less. Redis data can vanish (TTL, restart) with no fallback
Dedicated NATS worker consuming events, writing to Postgres ❌ Rejected A whole standing process, container, and silent-failure surface (worker dies → last_active quietly stops updating, no error, no visible symptom) for a job that is one UPDATE statement. Mismatch between infrastructure weight and what it does at this project's scale
Throttled inline write on the fast path Chosen Compare cached last_active against a threshold; only write if stale. Turns "0 DB queries always" into "usually 0, occasionally 1 cheap indexed write." No new infrastructure. Postgres stays the single source of truth. Same pattern real systems use, sized for our constraints

Throttle mechanics

  • Threshold: SESSION_ACTIVITY_THROTTLE_SECONDS (default 60s, configurable in app/core/config.py)
  • Fast path (app/deps/token_auth.py): after Redis cache hit, compare now - cached.last_active. If stale, call update_session_activity via the request's already-open DB connection, then re-cache with fresh timestamp.
  • Slow path: cache_session_for_auth populates the cache with last_active from the freshly-read Postgres row — no extra write. The session was just reconstructed; writing again would be pure waste.
  • Docstring updated to describe "usually 0, occasionally 1" reality instead of the previous absolute "0 DB queries" claim.

Eviction logic

if existing_session is None:
    sessions: list[UserSession] = []
    async for s in self.session_querier.list_sessions_by_user(user_id=user_id):
        sessions.append(s)

    if len(sessions) >= AuthService.SESSION_LIMIT:
        oldest = min(sessions, key=lambda s: (s.last_active, s.created_at))
        await SessionService.delete_session_cache(redis, oldest.id)
        await self.session_querier.delete_session_by_id(id=oldest.id, user_id=user_id)
        logger.warning("session_evicted user_id=%s evicted_session_id=%s", user_id, oldest.id)
  • Reuses existing list_sessions_by_user query; sorts in Python (cap is only 3 rows — no new SQL needed).
  • delete_session_by_id removes the DB row; delete_session_cache removes Redis. Order matters: DB first, cache second.
  • logger.warning(...) on eviction — consistent with existing session_limit_reached pattern, no new metrics dependency.

Test reactivation

The old tests/unit/test_enroll_security.py imported from app.router.mobile.enrollement (old location, double-L spelling). That module was refactored to app.core.image_validation but the tests were never updated — they just skipped via pytest.importorskip. This PR:

  • Removes the pytest.importorskip guard
  • Imports directly from app.core.image_validation
  • Updates function names to match the public API (no _ prefix)
  • Removes _enrollment_lock_key tests (that helper doesn't exist in the refactored module)
  • All 31 enrollment security tests now pass

Files changed

File Change
app/core/config.py Added SESSION_ACTIVITY_THROTTLE_SECONDS: int = 60
app/core/image_validation.py Fixed .. path traversal in sanitise_filename
app/deps/token_auth.py Throttled last_active update on fast path; updated docstring; slow path passes last_active to cache
app/service/session.py Added last_active: datetime to MobileSessionCache; updated cache_session_for_auth signature
app/service/users.py Replaced hard-reject cap branch with LRU eviction logic
app/service/device.py Removed dead create_device method with contradictory cap
db/queries/devices.sql Added GetDeviceByPhysicalId and GetAnyDeviceByPhysicalId queries
tests/unit/test_enroll_security.py Reactivated tests for refactored app.core.image_validation; fixed import path and function names
tests/unit/test_auth_service.py Rewrote TestSessionLimit to assert eviction (oldest session deleted, new session created) rather than 403 rejection
tests/unit/test_mobile_auth_intent_validation.py Added list_sessions_by_user to stateful fake; confirmed eviction behavior; added regression tests
tests/integration/test_session_device_management.py Added real-DB tests for atomic upsert and cascade delete

Testing

Unit tests

  • Rewrote TestSessionLimit in test_auth_service.py: asserts delete_session_by_id is called on the oldest session, not a 403 exception.
  • Updated all fakes/mocks for list_sessions_by_user (async generator, keyword-only user_id).
  • test_mobile_auth_intent_validation.py — added list_sessions_by_user to stateful fake; confirmed eviction behavior.
  • test_enroll_security.py — all 31 tests now pass (previously skipped entirely).

Integration tests (real Postgres)

  • test_relogin_on_same_device_replaces_not_duplicates_real_db — confirms atomic upsert still works end-to-end.
  • test_revoke_device_cascades_delete_session_real_db — confirms cascade delete still works.

@ademboukabes
ademboukabes merged commit ad20628 into develop Jul 18, 2026
2 checks passed
@ademboukabes
ademboukabes deleted the fix/session-and-device-management branch July 18, 2026 08: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.

2 participants