From 2e5b386e2f39d17a9f71c0cfd9a6f8efa4be63ef Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Mon, 20 Oct 2025 13:48:49 +0100 Subject: [PATCH] Add personal session data models to admin API --- crates/handlers/src/admin/model.rs | 185 ++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index c21e22fd7..71052a79e 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -7,9 +7,16 @@ use std::net::IpAddr; use chrono::{DateTime, Utc}; -use mas_data_model::Device; +use mas_data_model::{ + Device, + personal::{ + PersonalAccessToken as DataModelPersonalAccessToken, + session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner}, + }, +}; use schemars::JsonSchema; use serde::Serialize; +use thiserror::Error; use ulid::Ulid; use url::Url; @@ -771,3 +778,179 @@ impl UpstreamOAuthProvider { ] } } + +/// An error that shouldn't happen in practice, but suggests database +/// inconsistency. +#[derive(Debug, Error)] +#[error( + "personal session {session_id} in inconsistent state: not revoked but no valid access token" +)] +pub struct InconsistentPersonalSession { + pub session_id: Ulid, +} + +// Note: we don't expose a separate concept of personal access tokens to the +// admin API; we merge the relevant attributes into the personal session. +/// A personal session (session using personal access tokens) +#[derive(Serialize, JsonSchema)] +pub struct PersonalSession { + #[serde(skip)] + id: Ulid, + + /// When the session was created + created_at: DateTime, + + /// When the session was revoked, if applicable + revoked_at: Option>, + + /// The ID of the user who owns this session (if user-owned) + #[schemars(with = "super::schema::Ulid")] + owner_user_id: Option, + + /// The ID of the `OAuth2` client that owns this session (if client-owned) + #[schemars(with = "super::schema::Ulid")] + owner_client_id: Option, + + /// The ID of the user that the session acts on behalf of + #[schemars(with = "super::schema::Ulid")] + actor_user_id: Ulid, + + /// Human-readable name for the session + human_name: String, + + /// `OAuth2` scopes for this session + scope: String, + + /// When the session was last active + last_active_at: Option>, + + /// IP address of last activity + last_active_ip: Option, + + /// When the current token for this session expires. + /// The session will need to be regenerated, producing a new access token, + /// after this time. + /// None if the current token won't expire or if the session is revoked. + expires_at: Option>, + + /// The actual access token (only returned on creation) + #[serde(skip_serializing_if = "Option::is_none")] + access_token: Option, +} + +impl + TryFrom<( + DataModelPersonalSession, + Option, + )> for PersonalSession +{ + type Error = InconsistentPersonalSession; + + fn try_from( + (session, token): ( + DataModelPersonalSession, + Option, + ), + ) -> Result { + let expires_at = if let Some(token) = token { + token.expires_at + } else { + if !session.is_revoked() { + // No active token, but the session is not revoked. + return Err(InconsistentPersonalSession { + session_id: session.id, + }); + } + None + }; + + let (owner_user_id, owner_client_id) = match session.owner { + PersonalSessionOwner::User(id) => (Some(id), None), + PersonalSessionOwner::OAuth2Client(id) => (None, Some(id)), + }; + + Ok(Self { + id: session.id, + created_at: session.created_at, + revoked_at: session.revoked_at(), + owner_user_id, + owner_client_id, + actor_user_id: session.actor_user_id, + human_name: session.human_name, + scope: session.scope.to_string(), + last_active_at: session.last_active_at, + last_active_ip: session.last_active_ip, + expires_at, + // If relevant, the caller will populate using `with_token` afterwards. + access_token: None, + }) + } +} + +impl Resource for PersonalSession { + const KIND: &'static str = "personal-session"; + const PATH: &'static str = "/api/admin/v1/personal-sessions"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl PersonalSession { + /// Sample personal sessions for documentation/testing + pub fn samples() -> [Self; 3] { + [ + Self { + id: Ulid::from_string("01FSHN9AG0AJ6AC5HQ9X6H4RP4").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_000, 0).unwrap(), /* 2022-01-16T14: + * 40:00Z */ + revoked_at: None, + owner_user_id: Some(Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap()), + owner_client_id: None, + actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(), + human_name: "Alice's Development Token".to_owned(), + scope: "openid urn:matrix:org.matrix.msc2967.client:api:*".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_347_000, 0).unwrap()), /* 2022-01-16T17:10:00Z */ + last_active_ip: Some("192.168.1.100".parse().unwrap()), + expires_at: None, + access_token: None, + }, + Self { + id: Ulid::from_string("01FSHN9AG0BJ6AC5HQ9X6H4RP5").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_060, 0).unwrap(), /* 2022-01-16T14: + * 41:00Z */ + revoked_at: Some(DateTime::from_timestamp(1_642_350_000, 0).unwrap()), /* 2022-01-16T18:00:00Z */ + owner_user_id: Some(Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap()), + owner_client_id: None, + actor_user_id: Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap(), + human_name: "Bob's Mobile App".to_owned(), + scope: "openid".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_349_000, 0).unwrap()), /* 2022-01-16T17:43:20Z */ + last_active_ip: Some("10.0.0.50".parse().unwrap()), + expires_at: None, + access_token: None, + }, + Self { + id: Ulid::from_string("01FSHN9AG0CJ6AC5HQ9X6H4RP6").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_120, 0).unwrap(), /* 2022-01-16T14: + * 42:00Z */ + revoked_at: None, + owner_user_id: None, + owner_client_id: Some(Ulid::from_string("01FSHN9AG0DJ6AC5HQ9X6H4RP7").unwrap()), + actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(), + human_name: "CI/CD Pipeline Token".to_owned(), + scope: "openid urn:mas:admin".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_348_000, 0).unwrap()), /* 2022-01-16T17:26:40Z */ + last_active_ip: Some("203.0.113.10".parse().unwrap()), + expires_at: Some(DateTime::from_timestamp(1_642_999_000, 0).unwrap()), + access_token: None, + }, + ] + } + + /// Add the actual token value (for use in creation responses) + pub fn with_token(mut self, access_token: String) -> Self { + self.access_token = Some(access_token); + self + } +}