Skip to main content

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] }