Add personal session data models to admin API

This commit is contained in:
Olivier 'reivilibre
2025-10-20 14:33:30 +01:00
parent 01c89cd122
commit 2e5b386e2f
+184 -1
View File
@@ -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<Utc>,
/// When the session was revoked, if applicable
revoked_at: Option<DateTime<Utc>>,
/// The ID of the user who owns this session (if user-owned)
#[schemars(with = "super::schema::Ulid")]
owner_user_id: Option<Ulid>,
/// The ID of the `OAuth2` client that owns this session (if client-owned)
#[schemars(with = "super::schema::Ulid")]
owner_client_id: Option<Ulid>,
/// 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<DateTime<Utc>>,
/// IP address of last activity
last_active_ip: Option<IpAddr>,
/// 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<DateTime<Utc>>,
/// The actual access token (only returned on creation)
#[serde(skip_serializing_if = "Option::is_none")]
access_token: Option<String>,
}
impl
TryFrom<(
DataModelPersonalSession,
Option<DataModelPersonalAccessToken>,
)> for PersonalSession
{
type Error = InconsistentPersonalSession;
fn try_from(
(session, token): (
DataModelPersonalSession,
Option<DataModelPersonalAccessToken>,
),
) -> Result<Self, InconsistentPersonalSession> {
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
}
}