diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index f460f0c2a..22535504e 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -56,6 +56,13 @@ where .nest("/api/admin/v1", self::v1::router()) .finish_api_with(&mut api, |t| { t.title("Matrix Authentication Service admin API") + .tag(Tag { + name: "compat-session".to_owned(), + description: Some( + "Manage compatibility sessions from legacy clients".to_owned(), + ), + ..Tag::default() + }) .tag(Tag { name: "oauth2-session".to_owned(), description: Some("Manage OAuth2 sessions".to_owned()), diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index a0cbafda0..8bded28cb 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -7,9 +7,11 @@ use std::net::IpAddr; use chrono::{DateTime, Utc}; +use mas_data_model::Device; use schemars::JsonSchema; use serde::Serialize; use ulid::Ulid; +use url::Url; /// A resource, with a type and an ID pub trait Resource { @@ -147,6 +149,123 @@ impl UserEmail { } } +/// A compatibility session for legacy clients +#[derive(Serialize, JsonSchema)] +pub struct CompatSession { + #[serde(skip)] + pub id: Ulid, + + /// The ID of the user that owns this session + #[schemars(with = "super::schema::Ulid")] + pub user_id: Ulid, + + /// The Matrix device ID of this session + #[schemars(with = "super::schema::Device")] + pub device_id: Option, + + /// The ID of the user session that started this session, if any + #[schemars(with = "super::schema::Ulid")] + pub user_session_id: Option, + + /// The redirect URI used to login in the client, if it was an SSO login + pub redirect_uri: Option, + + /// The time this session was created + pub created_at: DateTime, + + /// The user agent string that started this session, if any + pub user_agent: Option, + + /// The time this session was last active + pub last_active_at: Option>, + + /// The last IP address recorded for this session + pub last_active_ip: Option, + + /// The time this session was finished + pub finished_at: Option>, +} + +impl + From<( + mas_data_model::CompatSession, + Option, + )> for CompatSession +{ + fn from( + (session, sso_login): ( + mas_data_model::CompatSession, + Option, + ), + ) -> Self { + let finished_at = session.finished_at(); + Self { + id: session.id, + user_id: session.user_id, + device_id: session.device, + user_session_id: session.user_session_id, + redirect_uri: sso_login.map(|sso| sso.redirect_uri), + created_at: session.created_at, + user_agent: session.user_agent.map(|ua| ua.raw), + last_active_at: session.last_active_at, + last_active_ip: session.last_active_ip, + finished_at, + } + } +} + +impl Resource for CompatSession { + const KIND: &'static str = "compat-session"; + const PATH: &'static str = "/api/admin/v1/compat-sessions"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl CompatSession { + pub fn samples() -> [Self; 3] { + [ + Self { + id: Ulid::from_bytes([0x01; 16]), + user_id: Ulid::from_bytes([0x01; 16]), + device_id: Some("AABBCCDDEE".to_owned().try_into().unwrap()), + user_session_id: Some(Ulid::from_bytes([0x11; 16])), + redirect_uri: Some("https://example.com/redirect".parse().unwrap()), + created_at: DateTime::default(), + user_agent: Some("Mozilla/5.0".to_owned()), + last_active_at: Some(DateTime::default()), + last_active_ip: Some([1, 2, 3, 4].into()), + finished_at: None, + }, + Self { + id: Ulid::from_bytes([0x02; 16]), + user_id: Ulid::from_bytes([0x01; 16]), + device_id: Some("FFGGHHIIJJ".to_owned().try_into().unwrap()), + user_session_id: Some(Ulid::from_bytes([0x12; 16])), + redirect_uri: None, + created_at: DateTime::default(), + user_agent: Some("Mozilla/5.0".to_owned()), + last_active_at: Some(DateTime::default()), + last_active_ip: Some([1, 2, 3, 4].into()), + finished_at: Some(DateTime::default()), + }, + Self { + id: Ulid::from_bytes([0x03; 16]), + user_id: Ulid::from_bytes([0x01; 16]), + device_id: None, + user_session_id: None, + redirect_uri: None, + created_at: DateTime::default(), + user_agent: None, + last_active_at: None, + last_active_ip: None, + finished_at: None, + }, + ] + } +} + /// A OAuth 2.0 session #[derive(Serialize, JsonSchema)] pub struct OAuth2Session { diff --git a/crates/handlers/src/admin/schema.rs b/crates/handlers/src/admin/schema.rs index 4f14f5346..d0c6b7f80 100644 --- a/crates/handlers/src/admin/schema.rs +++ b/crates/handlers/src/admin/schema.rs @@ -46,3 +46,34 @@ impl JsonSchema for Ulid { .into() } } + +/// A type to use for schema definitions of device IDs +/// +/// Use with `#[schemars(with = "crate::admin::schema::Device")]` +pub struct Device; + +impl JsonSchema for Device { + fn schema_name() -> String { + "DeviceID".to_owned() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + + metadata: Some(Box::new(Metadata { + title: Some("Device ID".into()), + examples: vec!["AABBCCDDEE".into(), "FFGGHHIIJJ".into()], + ..Metadata::default() + })), + + string: Some(Box::new(StringValidation { + pattern: Some(r"^[A-Za-z0-9._~!$&'()*+,;=:&/-]+$".into()), + ..StringValidation::default() + })), + + ..SchemaObject::default() + } + .into() + } +} diff --git a/crates/handlers/src/admin/v1/compat_sessions/get.rs b/crates/handlers/src/admin/v1/compat_sessions/get.rs new file mode 100644 index 000000000..ba48f1afd --- /dev/null +++ b/crates/handlers/src/admin/v1/compat_sessions/get.rs @@ -0,0 +1,159 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{transform::TransformOperation, OperationIo}; +use axum::{response::IntoResponse, Json}; +use hyper::StatusCode; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::CompatSession, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Compatibility session ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getCompatSession") + .summary("Get a compatibility session") + .tag("compat-session") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = CompatSession::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Compatibility session was found") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Compatibility session was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.compat_sessions.get", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let session = repo + .compat_session() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound(*id))?; + + let sso_login = repo.compat_sso_login().find_for_session(&session).await?; + + Ok(Json(SingleResponse::new_canonical(CompatSession::from(( + session, sso_login, + ))))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use mas_data_model::Device; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a compat session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device, None, false) + .await + .unwrap(); + repo.save().await.unwrap(); + + let session_id = session.id; + let request = Request::get(format!("/api/admin/v1/compat-sessions/{session_id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "data": { + "type": "compat-session", + "id": "01FSHN9AG0QHEHKX2JNQ2A2D07", + "attributes": { + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "device_id": "TpLoieH5Ie", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:40:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07" + } + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let session_id = Ulid::nil(); + let request = Request::get(format!("/api/admin/v1/compat-sessions/{session_id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/crates/handlers/src/admin/v1/compat_sessions/list.rs b/crates/handlers/src/admin/v1/compat_sessions/list.rs new file mode 100644 index 000000000..a27f1c29a --- /dev/null +++ b/crates/handlers/src/admin/v1/compat_sessions/list.rs @@ -0,0 +1,450 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{transform::TransformOperation, OperationIo}; +use axum::{ + extract::{rejection::QueryRejection, Query}, + response::IntoResponse, + Json, +}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_storage::{compat::CompatSessionFilter, Page}; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{CompatSession, Resource}, + params::Pagination, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Deserialize, JsonSchema, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum CompatSessionStatus { + Active, + Finished, +} + +impl std::fmt::Display for CompatSessionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Active => write!(f, "active"), + Self::Finished => write!(f, "finished"), + } + } +} + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "CompatSessionFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Retrieve the items for the given user + #[serde(rename = "filter[user]")] + #[schemars(with = "Option")] + user: Option, + + /// Retrieve the items started from the given browser session + #[serde(rename = "filter[user-session]")] + #[schemars(with = "Option")] + user_session: Option, + + /// Retrieve the items with the given status + /// + /// Defaults to retrieve all sessions, including finished ones. + /// + /// * `active`: Only retrieve active sessions + /// + /// * `finished`: Only retrieve finished sessions + #[serde(rename = "filter[status]")] + status: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(user) = self.user { + write!(f, "{sep}filter[user]={user}")?; + sep = '&'; + } + + if let Some(user_session) = self.user_session { + write!(f, "{sep}filter[user-session]={user_session}")?; + sep = '&'; + } + + if let Some(status) = self.status { + write!(f, "{sep}filter[status]={status}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User ID {0} not found")] + UserNotFound(Ulid), + + #[error("User session ID {0} not found")] + UserSessionNotFound(Ulid), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound(_) | Self::UserSessionNotFound(_) => StatusCode::NOT_FOUND, + Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listCompatSessions") + .summary("List compatibility sessions") + .description("Retrieve a list of compatibility sessions. +Note that by default, all sessions, including finished ones are returned, with the oldest first. +Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.") + .tag("compat-session") + .response_with::<200, Json>, _>(|t| { + let sessions = CompatSession::samples(); + let pagination = mas_storage::Pagination::first(sessions.len()); + let page = Page { + edges: sessions.into(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of compatibility sessions") + .example(PaginatedResponse::new( + page, + pagination, + 42, + CompatSession::PATH, + )) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.compat_sessions.list", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = CompatSession::PATH); + let filter = CompatSessionFilter::default(); + + // Load the user from the filter + let user = if let Some(user_id) = params.user { + let user = repo + .user() + .lookup(user_id) + .await? + .ok_or(RouteError::UserNotFound(user_id))?; + + Some(user) + } else { + None + }; + + let filter = match &user { + Some(user) => filter.for_user(user), + None => filter, + }; + + let user_session = if let Some(user_session_id) = params.user_session { + let user_session = repo + .browser_session() + .lookup(user_session_id) + .await? + .ok_or(RouteError::UserSessionNotFound(user_session_id))?; + + Some(user_session) + } else { + None + }; + + let filter = match &user_session { + Some(user_session) => filter.for_browser_session(user_session), + None => filter, + }; + + let filter = match params.status { + Some(CompatSessionStatus::Active) => filter.active_only(), + Some(CompatSessionStatus::Finished) => filter.finished_only(), + None => filter, + }; + + let page = repo.compat_session().list(filter, pagination).await?; + let count = repo.compat_session().count(filter).await?; + + Ok(Json(PaginatedResponse::new( + page.map(CompatSession::from), + pagination, + count, + &base, + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use mas_data_model::Device; + use sqlx::PgPool; + + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_compat_session_list(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision two users, one compat session for each, and finish one of them + let mut repo = state.repository().await.unwrap(); + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + state.clock.advance(Duration::minutes(1)); + + let bob = repo + .user() + .add(&mut rng, &state.clock, "bob".to_owned()) + .await + .unwrap(); + + let device = Device::generate(&mut rng); + repo.compat_session() + .add(&mut rng, &state.clock, &alice, device, None, false) + .await + .unwrap(); + let device = Device::generate(&mut rng); + + state.clock.advance(Duration::minutes(1)); + + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &bob, device, None, false) + .await + .unwrap(); + state.clock.advance(Duration::minutes(1)); + repo.compat_session() + .finish(&state.clock, session) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get("/api/admin/v1/compat-sessions") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "compat-session", + "id": "01FSHNB530AAPR7PEV8KNBZD5Y", + "attributes": { + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "device_id": "LoieH5Iecx", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:41:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + } + }, + { + "type": "compat-session", + "id": "01FSHNCZP0PPF7X0EVMJNECPZW", + "attributes": { + "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4", + "device_id": "ZXyvelQWW9", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:42:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": "2022-01-16T14:43:00Z" + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?page[first]=10", + "first": "/api/admin/v1/compat-sessions?page[first]=10", + "last": "/api/admin/v1/compat-sessions?page[last]=10" + } + } + "###); + + // Filter by user + let request = Request::get(format!( + "/api/admin/v1/compat-sessions?filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "compat-session", + "id": "01FSHNB530AAPR7PEV8KNBZD5Y", + "attributes": { + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "device_id": "LoieH5Iecx", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:41:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10", + "first": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10", + "last": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" + } + } + "###); + + // Filter by status (active) + let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=active") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "compat-session", + "id": "01FSHNB530AAPR7PEV8KNBZD5Y", + "attributes": { + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "device_id": "LoieH5Iecx", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:41:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?filter[status]=active&page[first]=10", + "first": "/api/admin/v1/compat-sessions?filter[status]=active&page[first]=10", + "last": "/api/admin/v1/compat-sessions?filter[status]=active&page[last]=10" + } + } + "###); + + // Filter by status (finished) + let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=finished") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "compat-session", + "id": "01FSHNCZP0PPF7X0EVMJNECPZW", + "attributes": { + "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4", + "device_id": "ZXyvelQWW9", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:42:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": "2022-01-16T14:43:00Z" + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?filter[status]=finished&page[first]=10", + "first": "/api/admin/v1/compat-sessions?filter[status]=finished&page[first]=10", + "last": "/api/admin/v1/compat-sessions?filter[status]=finished&page[last]=10" + } + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/compat_sessions/mod.rs b/crates/handlers/src/admin/v1/compat_sessions/mod.rs new file mode 100644 index 000000000..23c05c416 --- /dev/null +++ b/crates/handlers/src/admin/v1/compat_sessions/mod.rs @@ -0,0 +1,12 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +mod get; +mod list; + +pub use self::{ + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, +}; diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index d4afef951..9cc91be4a 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -15,6 +15,7 @@ use mas_storage::BoxRng; use super::call_context::CallContext; use crate::passwords::PasswordManager; +mod compat_sessions; mod oauth2_sessions; mod user_emails; mod users; @@ -28,6 +29,14 @@ where CallContext: FromRequestParts, { ApiRouter::::new() + .api_route( + "/compat-sessions", + get_with(self::compat_sessions::list, self::compat_sessions::list_doc), + ) + .api_route( + "/compat-sessions/{id}", + get_with(self::compat_sessions::get, self::compat_sessions::get_doc), + ) .api_route( "/oauth2-sessions", get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc), diff --git a/docs/api/spec.json b/docs/api/spec.json index d6e774ee5..b05193470 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -16,6 +16,268 @@ } ], "paths": { + "/api/admin/v1/compat-sessions": { + "get": { + "tags": [ + "compat-session" + ], + "summary": "List compatibility sessions", + "description": "Retrieve a list of compatibility sessions.\nNote that by default, all sessions, including finished ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.", + "operationId": "listCompatSessions", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[user]", + "description": "Retrieve the items for the given user", + "schema": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[user-session]", + "description": "Retrieve the items started from the given browser session", + "schema": { + "description": "Retrieve the items started from the given browser session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[status]", + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", + "schema": { + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", + "$ref": "#/components/schemas/CompatSessionStatus", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of compatibility sessions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_CompatSession" + }, + "example": { + "meta": { + "count": 42 + }, + "data": [ + { + "type": "compat-session", + "id": "01040G2081040G2081040G2081", + "attributes": { + "user_id": "01040G2081040G2081040G2081", + "device_id": "AABBCCDDEE", + "user_session_id": "0H248H248H248H248H248H248H", + "redirect_uri": "https://example.com/redirect", + "created_at": "1970-01-01T00:00:00Z", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "1.2.3.4", + "finished_at": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" + } + }, + { + "type": "compat-session", + "id": "02081040G2081040G2081040G2", + "attributes": { + "user_id": "01040G2081040G2081040G2081", + "device_id": "FFGGHHIIJJ", + "user_session_id": "0J289144GJ289144GJ289144GJ", + "redirect_uri": null, + "created_at": "1970-01-01T00:00:00Z", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "1.2.3.4", + "finished_at": "1970-01-01T00:00:00Z" + }, + "links": { + "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2" + } + }, + { + "type": "compat-session", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "user_id": "01040G2081040G2081040G2081", + "device_id": null, + "user_session_id": null, + "redirect_uri": null, + "created_at": "1970-01-01T00:00:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/030C1G60R30C1G60R30C1G60R3" + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?page[first]=3", + "first": "/api/admin/v1/compat-sessions?page[first]=3", + "last": "/api/admin/v1/compat-sessions?page[last]=3", + "next": "/api/admin/v1/compat-sessions?page[after]=030C1G60R30C1G60R30C1G60R3&page[first]=3" + } + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/compat-sessions/{id}": { + "get": { + "tags": [ + "compat-session" + ], + "summary": "Get a compatibility session", + "operationId": "getCompatSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Compatibility session was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_CompatSession" + }, + "example": { + "data": { + "type": "compat-session", + "id": "01040G2081040G2081040G2081", + "attributes": { + "user_id": "01040G2081040G2081040G2081", + "device_id": "AABBCCDDEE", + "user_session_id": "0H248H248H248H248H248H248H", + "redirect_uri": "https://example.com/redirect", + "created_at": "1970-01-01T00:00:00Z", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "1.2.3.4", + "finished_at": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "Compatibility session was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Compatibility session ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/oauth2-sessions": { "get": { "tags": [ @@ -1329,7 +1591,7 @@ "type": "string", "pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$" }, - "OAuth2SessionFilter": { + "CompatSessionFilter": { "type": "object", "properties": { "filter[user]": { @@ -1337,39 +1599,26 @@ "$ref": "#/components/schemas/ULID", "nullable": true }, - "filter[client]": { - "description": "Retrieve the items for the given client", - "$ref": "#/components/schemas/ULID", - "nullable": true - }, "filter[user-session]": { "description": "Retrieve the items started from the given browser session", "$ref": "#/components/schemas/ULID", "nullable": true }, - "filter[scope]": { - "description": "Retrieve the items with the given scope", - "default": [], - "type": "array", - "items": { - "type": "string" - } - }, "filter[status]": { "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", - "$ref": "#/components/schemas/OAuth2SessionStatus", + "$ref": "#/components/schemas/CompatSessionStatus", "nullable": true } } }, - "OAuth2SessionStatus": { + "CompatSessionStatus": { "type": "string", "enum": [ "active", "finished" ] }, - "PaginatedResponse_for_OAuth2Session": { + "PaginatedResponse_for_CompatSession": { "description": "A top-level response with a page of resources", "type": "object", "required": [ @@ -1386,7 +1635,7 @@ "description": "The list of resources", "type": "array", "items": { - "$ref": "#/components/schemas/SingleResource_for_OAuth2Session" + "$ref": "#/components/schemas/SingleResource_for_CompatSession" } }, "links": { @@ -1409,7 +1658,7 @@ } } }, - "SingleResource_for_OAuth2Session": { + "SingleResource_for_CompatSession": { "description": "A single resource, with its type, ID, attributes and related links", "type": "object", "required": [ @@ -1429,7 +1678,7 @@ }, "attributes": { "description": "The attributes of the resource", - "$ref": "#/components/schemas/OAuth2Session" + "$ref": "#/components/schemas/CompatSession" }, "links": { "description": "Related links", @@ -1437,63 +1686,73 @@ } } }, - "OAuth2Session": { - "description": "A OAuth 2.0 session", + "CompatSession": { + "description": "A compatibility session for legacy clients", "type": "object", "required": [ - "client_id", "created_at", - "scope" + "device_id", + "user_id", + "user_session_id" ], "properties": { - "created_at": { - "description": "When the object was created", - "type": "string", - "format": "date-time" - }, - "finished_at": { - "description": "When the session was finished", - "type": "string", - "format": "date-time", - "nullable": true - }, "user_id": { - "description": "The ID of the user who owns the session", - "$ref": "#/components/schemas/ULID", - "nullable": true + "description": "The ID of the user that owns this session", + "$ref": "#/components/schemas/ULID" }, - "user_session_id": { - "description": "The ID of the browser session which started this session", - "$ref": "#/components/schemas/ULID", - "nullable": true + "device_id": { + "description": "The Matrix device ID of this session", + "$ref": "#/components/schemas/DeviceID" }, - "client_id": { - "description": "The ID of the client which requested this session", + "user_session_id": { + "description": "The ID of the user session that started this session, if any", "$ref": "#/components/schemas/ULID" }, - "scope": { - "description": "The scope granted for this session", - "type": "string" + "redirect_uri": { + "description": "The redirect URI used to login in the client, if it was an SSO login", + "type": "string", + "format": "uri", + "nullable": true + }, + "created_at": { + "description": "The time this session was created", + "type": "string", + "format": "date-time" }, "user_agent": { - "description": "The user agent string of the client which started this session", + "description": "The user agent string that started this session, if any", "type": "string", "nullable": true }, "last_active_at": { - "description": "The last time the session was active", + "description": "The time this session was last active", "type": "string", "format": "date-time", "nullable": true }, "last_active_ip": { - "description": "The last IP address used by the session", + "description": "The last IP address recorded for this session", "type": "string", "format": "ip", "nullable": true + }, + "finished_at": { + "description": "The time this session was finished", + "type": "string", + "format": "date-time", + "nullable": true } } }, + "DeviceID": { + "title": "Device ID", + "examples": [ + "AABBCCDDEE", + "FFGGHHIIJJ" + ], + "type": "string", + "pattern": "^[A-Za-z0-9._~!$&'()*+,;=:&/-]+$" + }, "SelfLinks": { "description": "Related links", "type": "object", @@ -1581,6 +1840,173 @@ } } }, + "SingleResponse_for_CompatSession": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_CompatSession" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "OAuth2SessionFilter": { + "type": "object", + "properties": { + "filter[user]": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[client]": { + "description": "Retrieve the items for the given client", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[user-session]": { + "description": "Retrieve the items started from the given browser session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[scope]": { + "description": "Retrieve the items with the given scope", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "filter[status]": { + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", + "$ref": "#/components/schemas/OAuth2SessionStatus", + "nullable": true + } + } + }, + "OAuth2SessionStatus": { + "type": "string", + "enum": [ + "active", + "finished" + ] + }, + "PaginatedResponse_for_OAuth2Session": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "data", + "links", + "meta" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta" + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_OAuth2Session" + } + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_OAuth2Session": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/OAuth2Session" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "OAuth2Session": { + "description": "A OAuth 2.0 session", + "type": "object", + "required": [ + "client_id", + "created_at", + "scope" + ], + "properties": { + "created_at": { + "description": "When the object was created", + "type": "string", + "format": "date-time" + }, + "finished_at": { + "description": "When the session was finished", + "type": "string", + "format": "date-time", + "nullable": true + }, + "user_id": { + "description": "The ID of the user who owns the session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "user_session_id": { + "description": "The ID of the browser session which started this session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "client_id": { + "description": "The ID of the client which requested this session", + "$ref": "#/components/schemas/ULID" + }, + "scope": { + "description": "The scope granted for this session", + "type": "string" + }, + "user_agent": { + "description": "The user agent string of the client which started this session", + "type": "string", + "nullable": true + }, + "last_active_at": { + "description": "The last time the session was active", + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_active_ip": { + "description": "The last IP address used by the session", + "type": "string", + "format": "ip", + "nullable": true + } + } + }, "SingleResponse_for_OAuth2Session": { "description": "A top-level response with a single resource", "type": "object", @@ -1902,6 +2328,10 @@ } ], "tags": [ + { + "name": "compat-session", + "description": "Manage compatibility sessions from legacy clients" + }, { "name": "oauth2-session", "description": "Manage OAuth2 sessions"