cortex/frontend/sessions.rs
1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Active admin **sessions** management — the security-oversight completion of the session model
9//! (`models::session`, `docs/archive/WEBAUTHN_DESIGN.md`): see who is currently signed in (token or
10//! passkey) and revoke a compromised identity's sessions. Uniform authz — any signed-in admin may
11//! view + revoke.
12//!
13//! **Session ids are never exposed** (the id *is* the bearer credential): the read surface shows
14//! owner / method / times only, and revocation is **per-owner** (by the non-secret owner name), not
15//! per-opaque-id. "This is your current session" is computed server-side by comparing the row id to
16//! the request's cookie, without surfacing either.
17
18use rocket::http::{CookieJar, Status};
19use rocket::response::Redirect;
20use rocket::serde::json::Json;
21use rocket::{Route, State};
22use rocket_dyn_templates::{Template, context};
23use serde::Serialize;
24
25use crate::backend::DbPool;
26use crate::frontend::actor::{
27 ADMIN_COOKIE, Actor, AdminReject, AdminSession, ReturnTo, require_admin_to,
28};
29use crate::models::Session;
30
31/// An active admin session as exposed over the API/UI. **No session id** (it is the credential):
32/// owner + how they signed in + when, and whether it is the viewer's current browser session.
33#[derive(Debug, Serialize, schemars::JsonSchema)]
34pub struct SessionDto {
35 /// The signed-in identity (the audit-log actor / token owner).
36 pub owner: String,
37 /// How the session was established: `token` or `passkey`.
38 pub method: String,
39 /// When the session was opened, as an RFC 3339 UTC timestamp (localized to the viewer's zone in
40 /// the UI; directly parseable over the agent API).
41 pub created_at: String,
42 /// When the session expires, as an RFC 3339 UTC timestamp (localized to the viewer's zone in the
43 /// UI; directly parseable over the agent API).
44 pub expires_at: String,
45 /// Whether this row is the requesting browser's own current session (UI only; always `false`
46 /// over the agent API, which has no browser cookie).
47 pub current: bool,
48}
49
50/// Loads the active sessions as DTOs, marking the one whose id matches `current_id` (the viewer's
51/// cookie) — the shared core of the agent endpoint and the human screen (symmetry contract).
52fn load_sessions(pool: &DbPool, current_id: Option<&str>) -> Result<Vec<SessionDto>, Status> {
53 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
54 let sessions = Session::active(&mut connection).map_err(|_| Status::InternalServerError)?;
55 Ok(
56 sessions
57 .into_iter()
58 .map(|session| SessionDto {
59 current: current_id == Some(session.id.as_str()),
60 owner: session.owner,
61 method: session.method,
62 created_at: crate::frontend::helpers::iso_utc(session.created_at),
63 expires_at: crate::frontend::helpers::iso_utc(session.expires_at),
64 })
65 .collect(),
66 )
67}
68
69/// The active sessions (agent twin of the `/admin/sessions` screen): who is currently signed in.
70/// **Token-gated** (the active-identity list is sensitive). `503` if the pool is exhausted.
71#[rocket_okapi::openapi(tag = "Management")]
72#[get("/api/sessions")]
73pub fn api_sessions(_caller: Actor, pool: &State<DbPool>) -> Result<Json<Vec<SessionDto>>, Status> {
74 Ok(Json(load_sessions(pool, None)?))
75}
76
77/// The active-sessions screen (`GET /admin/sessions`): who is signed in, with per-identity revoke.
78/// Signed-in admins only (unauthenticated → sign-in page, returning here).
79#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
80#[get("/admin/sessions")]
81pub fn sessions_page(
82 session: Option<AdminSession>,
83 return_to: ReturnTo,
84 cookies: &CookieJar<'_>,
85 pool: &State<DbPool>,
86) -> Result<Template, AdminReject> {
87 let session = require_admin_to(session, &return_to)?;
88 let current_id = cookies
89 .get(ADMIN_COOKIE)
90 .map(|cookie| cookie.value().to_string());
91 // Best-effort, like the other admin screens: a db hiccup renders an empty table, never a 500.
92 let sessions = load_sessions(pool, current_id.as_deref()).unwrap_or_default();
93 let global = serde_json::json!({
94 "title": "Active sessions",
95 "description": "Admins currently signed in to CorTeX",
96 });
97 Ok(Template::render(
98 "sessions",
99 context! { global, owner: session.owner, sessions },
100 ))
101}
102
103/// Acknowledgement of a session revoke: the identity and how many of its sessions were ended.
104#[derive(Debug, Serialize, schemars::JsonSchema)]
105pub struct RevokeAckDto {
106 /// The identity whose sessions were revoked.
107 pub owner: String,
108 /// How many active sessions were ended (0 if the identity had none).
109 pub revoked: usize,
110 /// The actor who performed the revoke (audit identity).
111 pub actor: String,
112}
113
114/// Revokes **all** of an identity's sessions (agent twin of the screen's revoke). **Token-gated**
115/// (the [`Actor`] guard) and audited — for automated security response (kick out a compromised
116/// account everywhere). Referenced by the non-secret owner name, never a session id. Idempotent: an
117/// identity with no sessions revokes `0`. Sessions are ephemeral auth state, not historical record,
118/// so this mutation is exposed to agents (unlike the immutable history tables).
119#[rocket_okapi::openapi(tag = "Management")]
120#[post("/api/sessions/revoke?<owner>")]
121pub fn api_revoke_sessions(
122 actor: Actor,
123 owner: String,
124 pool: &State<DbPool>,
125) -> Result<Json<RevokeAckDto>, Status> {
126 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
127 let revoked =
128 Session::revoke_all_for(&mut connection, &owner).map_err(|_| Status::InternalServerError)?;
129 Ok(Json(RevokeAckDto {
130 owner,
131 revoked,
132 actor: actor.owner,
133 }))
134}
135
136/// Revokes **all** of an identity's sessions (`POST /admin/sessions/revoke?<owner>`) — sign-out-
137/// everywhere, or kicking out a compromised account. Referenced by the non-secret owner name (never
138/// a session id). Redirects back to the screen. Signed-in admins only.
139#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
140#[post("/admin/sessions/revoke?<owner>")]
141pub fn revoke_owner_sessions(
142 owner: String,
143 session: Option<AdminSession>,
144 return_to: ReturnTo,
145 pool: &State<DbPool>,
146) -> Result<Redirect, AdminReject> {
147 require_admin_to(session, &return_to)?;
148 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
149 let _ = Session::revoke_all_for(&mut connection, &owner);
150 Ok(Redirect::to("/admin/sessions"))
151}
152
153/// The human sessions screen + revoke (the agent `api_sessions` is mounted via `frontend::apidoc`).
154pub fn routes() -> Vec<Route> { routes![sessions_page, revoke_owner_sessions] }