Skip to content

UN-4344 [FEAT] Signed-in sessions list and sign out everywhere API - #2354

Open
Deepak-Kesavan wants to merge 7 commits into
mainfrom
UN-4344-sessions-api
Open

Deepak-Kesavan wants to merge 7 commits into
mainfrom
UN-4344-sessions-api

Conversation

@Deepak-Kesavan

@Deepak-Kesavan Deepak-Kesavan commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

  • Adds an API for signed-in sessions: list where the caller is signed in, sign out of one session, or sign out everywhere (PB-T06 / Frontend v1, Document Studio page 18 Profile, frames P01 + P03).
  • New account_v2.SessionRecord model. Sessions are recorded at sign-in and their last-active time is refreshed at most once a minute.

Why

  • Django's session table can't be queried by user, because the user id sits inside the encoded payload. So until now there was no way to list a user's sessions or end them. The v1 Profile page (P3-T10, lane E) and PB-T20 (lane H) build on this.

How

  • Recording: a user_logged_in receiver writes a SessionRecord (user agent, REMOTE_ADDR, optional location). Every distribution reaches it, because OSS basic auth, the cloud Auth0 plugin and the cloud Azure AD plugin all call django.contrib.auth.login(). A user_logged_out receiver removes it. Both receivers log errors and never raise.
  • Activity: SessionTracker.touch() runs inside CustomAuthMiddleware, but only in its session-authenticated branch. That branch skips Platform API key Bearer requests, X-API-KEY internal-service calls, whitelisted paths and anonymous requests. The touch lives there, not in a middleware of its own, because cloud's PRODUCTION_MIDDLEWARE is a separate list, while every settings file already references CUSTOM_AUTH_MIDDLEWARE. A cache.add gate limits writes to one per session per minute. A session that has no record yet (signed in before deploy) gets one on its first request.
  • Liveness: django_session.expire_date decides whether a session is live. Records of dead sessions are pruned exactly for the user when they list their sessions or sign in. At each sign-in, a global sweep also drops any record idle for longer than SESSION_COOKIE_AGE, using the indexed last_active_at, so stored IPs outlive their session by at most one cookie age. A record recreated by a request that was in flight during a revoke is kept out of the list by the same liveness check.
  • Revoke: SessionStore(key).delete() clears both the cache entry and the DB row under cached_db. For the caller's own session it uses request.session.flush(), so the 204 also clears the cookie. Each revoke also runs LogService.remove_logs_on_logout, the same as logout does.
  • Sessions with no record: some live sessions have no SessionRecord: ones signed in before the deploy that have been idle since, and ones whose recording failed at sign-in (recording is best-effort). Revoke-all ends these too, without having to find them. User.session_generation is folded into get_session_auth_hash(), and revoke-all bumps it with an atomic F() + 1 UPDATE. Django's AuthenticationMiddleware checks that hash on every request, so any session signed in under the earlier generation is refused, recorded or not. Generation 0 hashes exactly as Django does, so deploying this signs nobody out. Both the current-key and the SECRET_KEY_FALLBACKS hashes include the generation. User.save() leaves session_generation out of a full save, so a save of a User loaded before the bump can't write the old value back. No session table is decoded. Recorded sessions are still deleted outright. Unrecorded ones keep their django_session row until their next request (which flushes it) or until they expire.
  • Identity provider hooks: a plugin may define list_idp_sessions(request) and revoke_idp_sessions(request, session_id=None). Both are optional and looked up with getattr, because the cloud plugins don't subclass AuthenticationService. OSS basic auth defines neither. They're called best-effort.
  • Location: the new setting SESSION_LOCATION_RESOLVER takes a dotted path to callable(ip) -> str | None. It's unset by default, so location is null. No GeoIP dependency.
  • User agent parsing: a small in-repo matcher, because backend/uv.lock has no UA parser. Anything it doesn't recognise comes back as null; it never guesses.

Contract

Base: /api/v1/unstract/{org_id}/users/sessions/. The org segment is only an auth gate (the caller's session must be on that org). The data is per user: the list returns all of the caller's sessions, on any org. Callers only ever see their own sessions.

Auth: same as other session-authenticated endpoints. This includes CSRF: POST/DELETE need the X-CSRFToken header like every other unsafe request from the SPA. Platform API key (Bearer) callers get 403; anonymous callers get 401.

// GET users/sessions/  → 200
type SignedInSession = {
  id: string;                 // opaque; uuid for our sessions
  browser: string | null;     // "Chrome" | "Edge" | "Firefox" | "Safari" | "Opera" | "Samsung Internet" | null
  os: string | null;          // "macOS" | "Windows" | "iOS" | "Android" | "ChromeOS" | "Linux" | null
  device_label: string | null;// "Chrome on macOS"; one part if only one is known; null if neither
  location: string | null;    // null unless a resolver is configured → frontend shows device + time only
  last_active: string;        // ISO 8601; refreshed at most once a minute ("active now" ≈ within ~1 min)
  created: string;            // ISO 8601; for sessions signed in before this shipped: first seen
  is_current: boolean;        // the session making this request
};
type ListSessionsResponse = SignedInSession[]; // current first, then last_active desc; not paginated
Method + path Result
GET users/sessions/ 200 SignedInSession[]
POST users/sessions/revoke-all/ 204, ends every session of the caller, the current one included
DELETE users/sessions/{id}/ 204; 404 if the id is unknown or belongs to someone else (no existence leak). Deleting the current session's id signs you out.
  • The raw IP is recorded but never returned.
  • After revoke-all, or DELETE of the current session: the 204 response clears the session cookie, and the next request returns 401. The client must route to sign-in (P3-T10 behaviour Basic workflow and adapter exception messages #4).
  • Identity provider caveat (for PB-T20, lane H): IdP sessions are not merged in this version, and revoke-all does not end the IdP's own session. The plugin hooks are optional and no plugin implements them yet. After "Sign out everywhere", the IdP's SSO cookie (Auth0 / Azure AD) can still silently sign the user back in on another device without a password. This matches the P03 copy "you can sign back in straight away". If a cloud plugin later implements list_idp_sessions(request) -> list[SignedInSession], those entries are appended after ours, with non-uuid ids; DELETE on such an id goes to revoke_idp_sessions(request, session_id=id). Revoke-all calls revoke_idp_sessions(request, session_id=None). Provider failures are logged and never fail the request.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. This PR adds new routes under users/ (nothing existed at users/sessions/), a new table, a new user.session_generation column, signal receivers, and one call in CustomAuthMiddleware's already-authenticated branch. No existing path, parameter, response field or default behaviour changes. /login, /callback and /session responses are untouched.
  • User.get_session_auth_hash() is unchanged at generation 0, the default for every existing user, so existing sessions stay valid. It changes only for a user who uses "sign out everywhere", and only to end their sessions. A full User.save() on an existing row now writes every field except session_generation. Saves with explicit update_fields, and inserts, are unchanged.
  • The middleware touch and the sign-in/sign-out receivers are best-effort: they log failures and never raise. The touch adds at most one UPDATE per session per minute. Sign-in now also runs two small DELETEs: an indexed idle sweep, and the user's own dead records.
  • Revoke-all on a user with many sessions makes one Redis delete_pattern call per session, through remove_logs_on_logout. That's the same per-session cost logout already has.

Database Migrations

  • account_v2/0011_user_session_generation_sessionrecord: adds user.session_generation (default 0) and creates the session_record table. Generated with makemigrations on top of main's 0010_user_profile_fields. It replaces this branch's earlier 0008_sessionrecord, which was never released. Any dev database that ran the earlier branch needs session_record dropped, and its account_v2 / 0008_sessionrecord row removed from django_migrations, before it can migrate.

Env Config

  • Optional SESSION_LOCATION_RESOLVER (dotted path). Unset means no location.

Relevant Docs

Related Issues or PRs

  • UN-4344 (PB-T06), epic UN-4257. Unblocks P3-T10 (lane E) and PB-T20 (lane H).
  • Cloud: no cloud PR needed. Recording reaches Auth0/Azure AD through django_login, and the touch through the shared CUSTOM_AUTH_MIDDLEWARE.

Dependencies Versions

  • None added.

Notes on Testing

  • New backend/account_v2/test_session_tracking.py (34 tests). It drives two independent clients through a trimmed production middleware chain:
    • two concurrent sessions are listed with the correct is_current and device info;
    • revoke-all → both get 401 and the cookie is cleared;
    • revoke-one → the other session keeps working;
    • another user's id or an unknown id → 404;
    • expired sessions are pruned;
    • logout forgets the record;
    • activity is refreshed at most once a minute;
    • a session with no record is recorded lazily;
    • the location resolver is used when configured;
    • the flush-path sign-in (a different user on the same browser) is recorded;
    • sign-in survives a recording failure;
    • revoke-all ends a session whose recording failed at sign-in, with no request from that browser in between;
    • revoke-all ends a pre-deploy session that has no record;
    • signing in again after revoke-all works;
    • a full save of a User loaded before revoke-all can't revive its sessions;
    • revoke-all still holds across a SECRET_KEY rotation with fallbacks;
    • generation 0 hashes exactly as Django does;
    • the Bearer / no-session guard;
    • UA parsing.
  • Cloud path: the cloud plugins live in the other repo, so they can't be imported here. IdentityProviderSessionTest swaps in a stand-in plugin whose is_authenticated requires session[Z_SESSION] the way Auth0Service/AzureADService do. With it, the test checks: the touch runs, revoke-all gets 401 for both sessions, IdP hooks are merged and called, and IdP failures are tolerated.
  • Full backend suite run locally after merging main: 2667 passed, 26 skipped. The run excluded the MSSQL destination-connector tests, which can't import pymssql on macOS, unrelated to this change. Existing auth suites pass unchanged: platform-key middleware, deployment auth, global key, MCP auth, the account_v2 last-admin guard.
  • Self-reviewed with the review-pr kit (security, data-destruction, DRF, tests lenses, each finding independently verified). All 8 findings are addressed in the second commit.

Verified live on a dev deployment

Deployed this branch (3eefbee9f, image snapshot.1793335986) to a cloud dev namespace (Auth0 sign-in). Migration account_v2.0011_user_session_generation_sessionrecord applied cleanly. Ran the endpoints from two independent signed-in browsers (separate cookie jars) as the same user:

Check Result
GET users/sessions/ ✅ 200, both sessions listed, the caller's first with is_current: true, device_label: "Chrome on macOS", location: null
DELETE users/sessions/{other id}/ ✅ 204. That browser's next /session call gets 401, and the caller's keeps working. An unknown id → 404, and a request with no X-CSRFToken → 403
POST users/sessions/revoke-all/ ✅ 204, response sets sessionid=""; Max-Age=0, and both browsers then get 401
Sign in again after revoke-all ✅ Works. The new session is listed as current, so the bumped session_generation doesn't lock the user out
Existing /api/v1/session ✅ Same 10 response keys as main (the existing 201 on GET is main's behaviour, unchanged)

Not run live: DELETE on another user's session id, which needs a second account (covered by the unit tests above).

Record each browser session at sign-in (user_logged_in, reached by every
auth plugin via django login) and refresh last-active from
CustomAuthMiddleware at most once a minute. Add org-scoped
users/sessions/ (list), users/sessions/revoke-all/ and
users/sessions/<id>/ (revoke one). Additive only.
…t-effort recording

- Revoke-all decoded every unrecorded live session platform-wide on each
  call, including anonymous pre-login sessions that never get a record.
  Confine it to sessions expiring before migration-applied time plus
  SESSION_COOKIE_AGE, so it covers only pre-rollout sessions and turns
  itself off a day after rollout.
- Prune records of dead sessions at sign-in so stored IPs do not outlive
  their session.
- Sign-in/sign-out recording failures are logged, never raised.
- Tests: device info in list, flush-path sign-in, IdP hook failures,
  browser-session guard, legacy-sweep bound.
Sign-in pruned every record whose session was not live, an anti-join of
session_record against all of django_session on the login path. Prune
only the signing-in user's records exactly, and sweep globally on an
indexed last_active_at older than SESSION_COOKIE_AGE (a live session is
touched on every authenticated request). 0008 regenerated with the index.
Document and test that a record recreated by an in-flight request after
revoke is kept out of the list by the django_session liveness check.
@greptile-apps

greptile-apps Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge, with non-blocking improvements needed for IP retention and optional location lookup failures.

Fix All in Claude CodeFindings

  1. P2 Expired sessions keep IP addresses ▶
  2. P2 Resolver errors hide new sessions ▶
Fix with agent prompt
### Issue 1
backend/account_v2/session_tracking.py:155-157
The global cleanup only runs when someone signs in. If nobody signs in again, expired `SessionRecord` rows can keep their raw IP addresses indefinitely. The age filter limits which rows a sweep deletes, not how long rows remain stored. Run cleanup on a schedule so the promised retention bound also holds during quiet periods.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 2
backend/account_v2/session_tracking.py:91-95
If `SESSION_LOCATION_RESOLVER` points to an unavailable module, session recording fails instead of falling back to no location. `_location_resolver()` runs before the `try`, and `_upsert` evaluates it before saving the record. Sign-in still succeeds, but new sessions stay absent from the list. Move resolver loading inside the guarded block.

```suggestion
    try:
        resolver = _location_resolver()
        if resolver is None:
            return None
        return resolver(ip_address)
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds signed-in session records and APIs to list sessions, end one session, or sign out everywhere.

  • Users can see their live sessions and the devices they use.
  • Users can end one session or sign out everywhere.

Diagram

sequenceDiagram
    participant Browser
    participant Auth as Django authentication
    participant Tracker as SessionTracker
    participant DB as Database
    Browser->>Auth: Sign in
    Auth->>Tracker: user_logged_in
    Tracker->>DB: Save SessionRecord
    Browser->>Tracker: List sessions
    Tracker->>DB: Check live session rows
    Tracker-->>Browser: Own live sessions
    Browser->>Tracker: Sign out everywhere
    Tracker->>DB: Increment session_generation
    Tracker->>DB: Delete recorded sessions
    Tracker-->>Browser: 204 and clear cookie
    Browser->>Auth: Next request
    Auth-->>Browser: Reject earlier session hash
Loading

Reviews (3) · Last reviewed commit: "Merge branch 'main' into UN-4344-session..." · Reviewed by Greptile

Comment thread backend/account_v2/session_tracking.py Outdated
# Conflicts:
#	backend/account_v2/serializer.py
Sign out everywhere only ended sessions it could find through a
SessionRecord, so a session whose recording failed at sign-in survived it.
User.session_generation is now folded into the session auth hash and
bumped on revoke-all, so Django refuses every session signed in under the
earlier generation, recorded or not. Generation 0 keeps Django's own hash,
so deploying signs nobody out. This replaces the migration-time-bounded
decode sweep for pre-rollout sessions.

Also addresses the SonarCloud issues: non-null SessionRecord.location,
a Safari user-agent pattern without backtracking, and the no-op IdP hooks
on AuthenticationService (the tracker already looks them up with getattr).
…m the previous commit

The previous commit only carried the removal of 0011_sessionrecord; this
adds the change it describes: User.session_generation in the session auth
hash, the revoke-all bump, the regenerated migration, the tests and the
SonarCloud fixes.
@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +155 to +157
idle_cutoff = timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)
SessionRecord.objects.filter(last_active_at__lt=idle_cutoff).delete()
cls.live_records(user)

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.

P2 Expired sessions keep IP addresses

The global cleanup only runs when someone signs in. If nobody signs in again, expired SessionRecord rows can keep their raw IP addresses indefinitely. The age filter limits which rows a sweep deletes, not how long rows remain stored. Run cleanup on a schedule so the promised retention bound also holds during quiet periods.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/account_v2/session_tracking.py
Line: 155-157

Comment:
**Expired sessions keep IP addresses**

The global cleanup only runs when someone signs in. If nobody signs in again, expired `SessionRecord` rows can keep their raw IP addresses indefinitely. The age filter limits which rows a sweep deletes, not how long rows remain stored. Run cleanup on a schedule so the promised retention bound also holds during quiet periods.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment on lines +91 to +95
resolver = _location_resolver()
if resolver is None:
return None
try:
return resolver(ip_address)

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.

P2 Resolver errors hide new sessions

If SESSION_LOCATION_RESOLVER points to an unavailable module, session recording fails instead of falling back to no location. _location_resolver() runs before the try, and _upsert evaluates it before saving the record. Sign-in still succeeds, but new sessions stay absent from the list. Move resolver loading inside the guarded block.

Suggested change
resolver = _location_resolver()
if resolver is None:
return None
try:
return resolver(ip_address)
try:
resolver = _location_resolver()
if resolver is None:
return None
return resolver(ip_address)
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/account_v2/session_tracking.py
Line: 91-95

Comment:
**Resolver errors hide new sessions**

If `SESSION_LOCATION_RESOLVER` points to an unavailable module, session recording fails instead of falling back to no location. `_location_resolver()` runs before the `try`, and `_upsert` evaluates it before saving the record. Sign-in still succeeds, but new sessions stay absent from the list. Move resolver loading inside the guarded block.

```suggestion
    try:
        resolver = _location_resolver()
        if resolver is None:
            return None
        return resolver(ip_address)
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
⚪ e2e-agent-kv e2e 0 0 0 1 0.1
✅ e2e-api-deployment e2e 3 0 0 0 18.6
✅ e2e-coowners e2e 1 0 0 0 1.8
✅ e2e-etl e2e 1 0 0 0 18.5
✅ e2e-login e2e 2 0 0 0 1.4
✅ e2e-prompt-studio e2e 1 0 0 0 11.2
✅ e2e-smoke e2e 2 0 0 0 1.4
✅ e2e-workflow e2e 1 0 0 0 14.8
✅ frontend unit 640 0 0 0 20.8
✅ integration-backend integration 1483 0 0 26 86.5
✅ integration-connectors integration 1 0 0 7 9.4
✅ integration-workers integration 181 0 0 1 57.6
❌ ui e2e 0 1 0 0 0.0
✅ unit-agent-kv-schema unit 143 0 0 0 1.9
✅ unit-backend unit 2121 0 0 1 63.0
✅ unit-connectors unit 82 0 0 0 11.7
✅ unit-core unit 281 0 0 0 3.3
✅ unit-filesystem unit 1 0 0 0 4.4
✅ unit-platform-service unit 18 0 0 0 2.8
✅ unit-rig unit 120 0 0 0 4.6
✅ unit-runner unit 12 0 0 0 3.1
✅ unit-sdk1 unit 878 0 0 0 42.2
✅ unit-tool-sidecar unit 8 0 0 0 1.8
✅ unit-workers unit 1589 0 0 2 133.1
TOTAL 7569 1 0 38 514.0

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • platform-key-whoami — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

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