From c2c75c2220eeda0021483662fe905a2414bc36ad Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Fri, 10 Oct 2025 14:14:11 +0100 Subject: [PATCH 1/5] Add personal session data models to admin API --- crates/handlers/src/admin/model.rs | 165 ++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index c21e22fd7..0b70b6086 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -7,7 +7,13 @@ 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 ulid::Ulid; @@ -771,3 +777,160 @@ impl UpstreamOAuthProvider { ] } } + +/// 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, +} + +impl From for PersonalSession { + fn from(session: DataModelPersonalSession) -> Self { + let (owner_user_id, owner_client_id) = match session.owner { + PersonalSessionOwner::User(id) => (Some(id), None), + PersonalSessionOwner::OAuth2Client(id) => (None, Some(id)), + }; + + 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, + } + } +} + +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()), + }, + 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()), + }, + 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()), + }, + ] + } +} + +/// A personal access token +#[derive(Serialize, JsonSchema)] +pub struct PersonalAccessToken { + /// The ID of the personal session this token belongs to + #[schemars(with = "super::schema::Ulid")] + session_id: Ulid, + + /// When the token was created + created_at: DateTime, + + /// When the token expires, if applicable + expires_at: Option>, + + /// When the token was revoked, if applicable + revoked_at: Option>, + + /// The actual access token (only returned on creation) + #[serde(skip_serializing_if = "Option::is_none")] + access_token: Option, +} + +impl From for PersonalAccessToken { + fn from(token: DataModelPersonalAccessToken) -> Self { + Self { + session_id: token.session_id, + created_at: token.created_at, + expires_at: token.expires_at, + revoked_at: token.revoked_at, + // Not available in data model + access_token: None, + } + } +} + +impl PersonalAccessToken { + /// 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 + } +} From d1da118e1d8c64a1c8d2d2f08a18d8e5f5fb412f Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Fri, 10 Oct 2025 14:17:38 +0100 Subject: [PATCH 2/5] Add personal sessions admin API --- crates/handlers/src/admin/v1/mod.rs | 26 + .../src/admin/v1/personal_sessions/add.rs | 286 +++++++ .../src/admin/v1/personal_sessions/get.rs | 176 +++++ .../src/admin/v1/personal_sessions/list.rs | 257 +++++++ .../src/admin/v1/personal_sessions/mod.rs | 16 + .../src/admin/v1/personal_sessions/revoke.rs | 234 ++++++ docs/api/spec.json | 700 ++++++++++++++++++ 7 files changed, 1695 insertions(+) create mode 100644 crates/handlers/src/admin/v1/personal_sessions/add.rs create mode 100644 crates/handlers/src/admin/v1/personal_sessions/get.rs create mode 100644 crates/handlers/src/admin/v1/personal_sessions/list.rs create mode 100644 crates/handlers/src/admin/v1/personal_sessions/mod.rs create mode 100644 crates/handlers/src/admin/v1/personal_sessions/revoke.rs diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index f6188e6da..e406f9b1a 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -20,6 +20,7 @@ use crate::passwords::PasswordManager; mod compat_sessions; mod oauth2_sessions; +mod personal_sessions; mod policy_data; mod site_config; mod upstream_oauth_links; @@ -80,6 +81,31 @@ where self::oauth2_sessions::finish_doc, ), ) + .api_route( + "/personal-sessions", + get_with( + self::personal_sessions::list, + self::personal_sessions::list_doc, + ) + .post_with( + self::personal_sessions::add, + self::personal_sessions::add_doc, + ), + ) + .api_route( + "/personal-sessions/{id}", + get_with( + self::personal_sessions::get, + self::personal_sessions::get_doc, + ), + ) + .api_route( + "/personal-sessions/{id}/revoke", + post_with( + self::personal_sessions::revoke, + self::personal_sessions::revoke_doc, + ), + ) .api_route( "/policy-data", post_with(self::policy_data::set, self::policy_data::set_doc), diff --git a/crates/handlers/src/admin/v1/personal_sessions/add.rs b/crates/handlers/src/admin/v1/personal_sessions/add.rs new file mode 100644 index 000000000..c9043887f --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/add.rs @@ -0,0 +1,286 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use chrono::Duration; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::{BoxRng, TokenType, personal::session::PersonalSessionOwner}; +use oauth2_types::scope::Scope; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{PersonalAccessToken, PersonalSession}, + response::ErrorResponse, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User not found")] + UserNotFound, + + #[error("Invalid scope")] + InvalidScope, +} + +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 sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound => StatusCode::NOT_FOUND, + Self::InvalidScope => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "CreatePersonalSessionRequest")] +pub struct Request { + /// The user this session will act on behalf of + #[schemars(with = "crate::admin::schema::Ulid")] + actor_user_id: Ulid, + + /// Human-readable name for the session + human_name: String, + + /// `OAuth2` scopes for this session + scope: String, + + /// Token expiry time in seconds. + /// If not set, the token won't expire. + expires_in: Option, +} + +/// Response containing both the personal session and access token +#[derive(Serialize, JsonSchema)] +#[serde(rename = "CreatePersonalSessionResponse")] +pub struct Response { + /// The created personal session + session: PersonalSession, + + /// The created personal access token + access_token: PersonalAccessToken, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("createPersonalSession") + .summary("Create a new personal session with personal access token") + .tag("personal-session") + .response_with::<201, Json, _>(|t| { + t.description("Personal session and personal access token were created") + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::InvalidScope); + t.description("Invalid scope provided").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound); + t.description("User was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)] +pub async fn handler( + CallContext { + mut repo, + clock, + session, + .. + }: CallContext, + NoApi(mut rng): NoApi, + Json(params): Json, +) -> Result<(StatusCode, Json), RouteError> { + let owner = if let Some(user_id) = session.user_id { + // User-owned session + PersonalSessionOwner::User(user_id) + } else { + // No admin user means this is a client-owned session + PersonalSessionOwner::OAuth2Client(session.client_id) + }; + + let actor_user = repo + .user() + .lookup(params.actor_user_id) + .await? + .ok_or(RouteError::UserNotFound)?; + + let scope: Scope = params.scope.parse().map_err(|_| RouteError::InvalidScope)?; + + // Create the personal session + let session = repo + .personal_session() + .add( + &mut rng, + &clock, + owner, + &actor_user, + params.human_name, + scope, + ) + .await?; + + // Create the initial token for the session + let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng); + let access_token = repo + .personal_access_token() + .add( + &mut rng, + &clock, + &session, + &access_token_string, + params + .expires_in + .map(|exp_in| Duration::seconds(i64::try_from(exp_in).unwrap_or(i64::MAX))), + ) + .await?; + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(Response { + session: PersonalSession::from(session), + access_token: PersonalAccessToken::from(access_token).with_token(access_token_string), + }), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use serde_json::Value; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_with_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request_body = serde_json::json!({ + "actor_user_id": user.id, + "human_name": "Test Session", + "scope": "openid urn:mas:admin", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let body: Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "session": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": null, + "owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR", + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Test Session", + "scope": "openid urn:mas:admin", + "last_active_at": null, + "last_active_ip": null + }, + "access_token": { + "session_id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "created_at": "2022-01-16T14:40:00Z", + "expires_at": "2022-01-16T15:40:00Z", + "revoked_at": null, + "access_token": "mpt_FM44zJN5qePGMLvvMXC4Ds1A3lCWc6_bJ9Wj1" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_invalid_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request_body = serde_json::json!({ + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "scope": "openid", + "human_name": "Test Session", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_invalid_scope(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request_body = serde_json::json!({ + "actor_user_id": user.id, + "human_name": "Test Session", + "scope": "invalid\nscope", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/get.rs b/crates/handlers/src/admin/v1/personal_sessions/get.rs new file mode 100644 index 000000000..df81fe823 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/get.rs @@ -0,0 +1,176 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; + +use crate::{ + admin::{ + call_context::CallContext, + model::PersonalSession, + 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("Personal session not found")] + NotFound, +} + +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 sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound => StatusCode::NOT_FOUND, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getPersonalSession") + .summary("Get a personal session") + .tag("personal-session") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = PersonalSession::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Personal session details").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound); + t.description("Personal session not found") + .example(response) + }) +} + +#[tracing::instrument( + name = "handler.admin.v1.personal_sessions.get", + skip_all, + fields(personal_session.id = %*id), +)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let session_id = *id; + + let session = repo + .personal_session() + .lookup(session_id) + .await? + .ok_or(RouteError::NotFound)?; + + // XXX should this include info about the token + Ok(Json(SingleResponse::new_canonical(PersonalSession::from( + session, + )))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use mas_data_model::personal::session::PersonalSessionOwner; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[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; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::get(format!( + "/api/admin/v1/personal-sessions/{}", + personal_session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["data"]["id"], personal_session.id.to_string()); + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Test session", + "scope": "openid", + "last_active_at": null, + "last_active_ip": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + "#); + } + + #[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/personal-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/personal_sessions/list.rs b/crates/handlers/src/admin/v1/personal_sessions/list.rs new file mode 100644 index 000000000..e341d0c3a --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/list.rs @@ -0,0 +1,257 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{ + Json, + extract::{Query, rejection::QueryRejection}, + response::IntoResponse, +}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::personal::PersonalSessionFilter; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{PersonalSession, Resource}, + params::{IncludeCount, Pagination}, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Deserialize, JsonSchema, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum PersonalSessionStatus { + Active, + Revoked, +} + +impl std::fmt::Display for PersonalSessionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Active => write!(f, "active"), + Self::Revoked => write!(f, "revoked"), + } + } +} + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "PersonalSessionFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Filter by owner user ID + #[serde(rename = "filter[owner_user]")] + #[schemars(with = "Option")] + owner_user: Option, + + /// Filter by owner `OAuth2` client ID + #[serde(rename = "filter[owner_client]")] + #[schemars(with = "Option")] + owner_client: Option, + + /// Filter by actor user ID + #[serde(rename = "filter[actor_user]")] + #[schemars(with = "Option")] + actor_user: Option, + + /// Filter by session status + #[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(owner_user) = self.owner_user { + write!(f, "{sep}filter[owner_user]={owner_user}")?; + sep = '&'; + } + if let Some(owner_client) = self.owner_client { + write!(f, "{sep}filter[owner_client]={owner_client}")?; + sep = '&'; + } + if let Some(actor_user) = self.actor_user { + write!(f, "{sep}filter[actor_user]={actor_user}")?; + 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("Client ID {0} not found")] + ClientNotFound(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 sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound(_) | Self::ClientNotFound(_) => StatusCode::NOT_FOUND, + Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listPersonalSessions") + .summary("List personal sessions") + .description("Retrieve a list of personal sessions. +Note that by default, all sessions, including revoked 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("personal-session") + .response_with::<200, Json>, _>(|t| { + let sessions = PersonalSession::samples(); + let pagination = mas_storage::Pagination::first(sessions.len()); + let page = mas_storage::Page { + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of personal sessions") + .example(PaginatedResponse::for_page( + page, + pagination, + Some(3), + PersonalSession::PATH, + )) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User was not found").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::ClientNotFound(Ulid::nil())); + t.description("Client was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.personal_sessions.list", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination, include_count): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = PersonalSession::PATH); + let base = include_count.add_to_base(&base); + + let filter = PersonalSessionFilter::new(); + + let owner_user = if let Some(owner_user_id) = params.owner_user { + let owner_user = repo + .user() + .lookup(owner_user_id) + .await? + .ok_or(RouteError::UserNotFound(owner_user_id))?; + Some(owner_user) + } else { + None + }; + + let filter = match &owner_user { + Some(user) => filter.for_owner_user(user), + None => filter, + }; + + let owner_client = if let Some(owner_client_id) = params.owner_client { + let owner_client = repo + .oauth2_client() + .lookup(owner_client_id) + .await? + .ok_or(RouteError::ClientNotFound(owner_client_id))?; + Some(owner_client) + } else { + None + }; + + let filter = match &owner_client { + Some(client) => filter.for_owner_oauth2_client(client), + None => filter, + }; + + let actor_user = if let Some(actor_user_id) = params.actor_user { + let user = repo + .user() + .lookup(actor_user_id) + .await? + .ok_or(RouteError::UserNotFound(actor_user_id))?; + Some(user) + } else { + None + }; + + let filter = match &actor_user { + Some(user) => filter.for_actor_user(user), + None => filter, + }; + + // Apply status filter + let filter = match params.status { + Some(PersonalSessionStatus::Active) => filter.active_only(), + Some(PersonalSessionStatus::Revoked) => filter.finished_only(), + None => filter, + }; + + let response = match include_count { + IncludeCount::True => { + let page = repo.personal_session().list(filter, pagination).await?; + let count = repo.personal_session().count(filter).await?; + PaginatedResponse::for_page( + page.map(PersonalSession::from), + pagination, + Some(count), + &base, + ) + } + IncludeCount::False => { + let page = repo.personal_session().list(filter, pagination).await?; + PaginatedResponse::for_page(page.map(PersonalSession::from), pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.personal_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; + + Ok(Json(response)) +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/mod.rs b/crates/handlers/src/admin/v1/personal_sessions/mod.rs new file mode 100644 index 000000000..6f52dd827 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/mod.rs @@ -0,0 +1,16 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +mod add; +mod get; +mod list; +mod revoke; + +pub use self::{ + add::{doc as add_doc, handler as add}, + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, + revoke::{doc as revoke_doc, handler as revoke}, +}; diff --git a/crates/handlers/src/admin/v1/personal_sessions/revoke.rs b/crates/handlers/src/admin/v1/personal_sessions/revoke.rs new file mode 100644 index 000000000..bbcb2ad68 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/revoke.rs @@ -0,0 +1,234 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::PersonalSession, + 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("Personal session with ID {0} not found")] + NotFound(Ulid), + + #[error("Personal session with ID {0} is already revoked")] + AlreadyRevoked(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 sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyRevoked(_) => StatusCode::CONFLICT, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("revokePersonalSession") + .summary("Revoke a personal session") + .tag("personal-session") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = PersonalSession::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Personal session was revoked") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Personal session not found") + .example(response) + }) + .response_with::<409, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyRevoked(Ulid::nil())); + t.description("Personal session already revoked") + .example(response) + }) +} + +#[tracing::instrument( + name = "handler.admin.v1.personal_sessions.revoke", + skip_all, + fields(personal_session.id = %*session_id), +)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + session_id: UlidPathParam, +) -> Result>, RouteError> { + let session_id = *session_id; + let session = repo + .personal_session() + .lookup(session_id) + .await? + .ok_or(RouteError::NotFound(session_id))?; + + if session.is_revoked() { + return Err(RouteError::AlreadyRevoked(session_id)); + } + + let session = repo.personal_session().revoke(&clock, session).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new_canonical(PersonalSession::from( + session, + )))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{Clock, personal::session::PersonalSessionOwner}; + use oauth2_types::scope::Scope; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([]), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post(format!( + "/api/admin/v1/personal-sessions/{}/revoke", + personal_session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The revoked_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["revoked_at"], + serde_json::json!(Clock::now(&state.clock)) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_already_revoked_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([]), + ) + .await + .unwrap(); + + // Revoke the session first + let session = repo + .personal_session() + .revoke(&state.clock, personal_session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/personal-sessions/{}/revoke", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::CONFLICT); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!("Personal session with ID {} is already revoked", session.id) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/personal-sessions/01040G2081040G2081040G2081/revoke") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "Personal session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/docs/api/spec.json b/docs/api/spec.json index 5dd1cfb83..725ea3adb 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -896,6 +896,458 @@ } } }, + "/api/admin/v1/personal-sessions": { + "get": { + "tags": [ + "personal-session" + ], + "summary": "List personal sessions", + "description": "Retrieve a list of personal sessions.\nNote that by default, all sessions, including revoked 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": "listPersonalSessions", + "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": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[owner_user]", + "description": "Filter by owner user ID", + "schema": { + "description": "Filter by owner user ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[owner_client]", + "description": "Filter by owner `OAuth2` client ID", + "schema": { + "description": "Filter by owner `OAuth2` client ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[actor_user]", + "description": "Filter by actor user ID", + "schema": { + "description": "Filter by actor user ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[status]", + "description": "Filter by session status", + "schema": { + "description": "Filter by session status", + "$ref": "#/components/schemas/PersonalSessionStatus", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of personal sessions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_PersonalSession" + }, + "example": { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + }, + { + "type": "personal-session", + "id": "01FSHN9AG0BJ6AC5HQ9X6H4RP5", + "attributes": { + "created_at": "2022-01-16T13:01:00Z", + "revoked_at": "2022-01-16T16:20:00Z", + "owner_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F", + "human_name": "Bob's Mobile App", + "scope": "openid", + "last_active_at": "2022-01-16T16:03:20Z", + "last_active_ip": "10.0.0.50" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0BJ6AC5HQ9X6H4RP5" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0BJ6AC5HQ9X6H4RP5" + } + } + }, + { + "type": "personal-session", + "id": "01FSHN9AG0CJ6AC5HQ9X6H4RP6", + "attributes": { + "created_at": "2022-01-16T13:02:00Z", + "revoked_at": null, + "owner_user_id": null, + "owner_client_id": "01FSHN9AG0DJ6AC5HQ9X6H4RP7", + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "CI/CD Pipeline Token", + "scope": "openid urn:mas:admin", + "last_active_at": "2022-01-16T15:46:40Z", + "last_active_ip": "203.0.113.10" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0CJ6AC5HQ9X6H4RP6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0CJ6AC5HQ9X6H4RP6" + } + } + } + ], + "links": { + "self": "/api/admin/v1/personal-sessions?page[first]=3", + "first": "/api/admin/v1/personal-sessions?page[first]=3", + "last": "/api/admin/v1/personal-sessions?page[last]=3", + "next": "/api/admin/v1/personal-sessions?page[after]=01FSHN9AG0CJ6AC5HQ9X6H4RP6&page[first]=3" + } + } + } + } + }, + "404": { + "description": "Client was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Client ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + }, + "post": { + "tags": [ + "personal-session" + ], + "summary": "Create a new personal session with personal access token", + "operationId": "createPersonalSession", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePersonalSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Personal session and personal access token were created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePersonalSessionResponse" + } + } + } + }, + "400": { + "description": "Invalid scope provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Invalid scope" + } + ] + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/personal-sessions/{id}": { + "get": { + "tags": [ + "personal-session" + ], + "summary": "Get a personal session", + "operationId": "getPersonalSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Personal session details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" + }, + "example": { + "data": { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + } + } + }, + "404": { + "description": "Personal session not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Personal session not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/personal-sessions/{id}/revoke": { + "post": { + "tags": [ + "personal-session" + ], + "summary": "Revoke a personal session", + "operationId": "revokePersonalSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Personal session was revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" + }, + "example": { + "data": { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + } + } + }, + "404": { + "description": "Personal session not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Personal session with ID 00000000000000000000000000 not found" + } + ] + } + } + } + }, + "409": { + "description": "Personal session already revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Personal session with ID 00000000000000000000000000 is already revoked" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/policy-data": { "post": { "tags": [ @@ -4579,6 +5031,254 @@ } } }, + "PersonalSessionFilter": { + "type": "object", + "properties": { + "filter[owner_user]": { + "description": "Filter by owner user ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[owner_client]": { + "description": "Filter by owner `OAuth2` client ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[actor_user]": { + "description": "Filter by actor user ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[status]": { + "description": "Filter by session status", + "$ref": "#/components/schemas/PersonalSessionStatus", + "nullable": true + } + } + }, + "PersonalSessionStatus": { + "type": "string", + "enum": [ + "active", + "revoked" + ] + }, + "PaginatedResponse_for_PersonalSession": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "links" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_PersonalSession" + }, + "nullable": true + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_PersonalSession": { + "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/PersonalSession" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true + } + } + }, + "PersonalSession": { + "description": "A personal session (session using personal access tokens)", + "type": "object", + "required": [ + "actor_user_id", + "created_at", + "human_name", + "owner_client_id", + "owner_user_id", + "scope" + ], + "properties": { + "created_at": { + "description": "When the session was created", + "type": "string", + "format": "date-time" + }, + "revoked_at": { + "description": "When the session was revoked, if applicable", + "type": "string", + "format": "date-time", + "nullable": true + }, + "owner_user_id": { + "description": "The ID of the user who owns this session (if user-owned)", + "$ref": "#/components/schemas/ULID" + }, + "owner_client_id": { + "description": "The ID of the `OAuth2` client that owns this session (if client-owned)", + "$ref": "#/components/schemas/ULID" + }, + "actor_user_id": { + "description": "The ID of the user that the session acts on behalf of", + "$ref": "#/components/schemas/ULID" + }, + "human_name": { + "description": "Human-readable name for the session", + "type": "string" + }, + "scope": { + "description": "`OAuth2` scopes for this session", + "type": "string" + }, + "last_active_at": { + "description": "When the session was last active", + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_active_ip": { + "description": "IP address of last activity", + "type": "string", + "format": "ip", + "nullable": true + } + } + }, + "CreatePersonalSessionRequest": { + "title": "JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint", + "type": "object", + "required": [ + "actor_user_id", + "human_name", + "scope" + ], + "properties": { + "actor_user_id": { + "description": "The user this session will act on behalf of", + "$ref": "#/components/schemas/ULID" + }, + "human_name": { + "description": "Human-readable name for the session", + "type": "string" + }, + "scope": { + "description": "`OAuth2` scopes for this session", + "type": "string" + }, + "expires_in": { + "description": "Token expiry time in seconds. If not set, the token won't expire.", + "type": "integer", + "format": "uint64", + "minimum": 0.0, + "nullable": true + } + } + }, + "CreatePersonalSessionResponse": { + "description": "Response containing both the personal session and access token", + "type": "object", + "required": [ + "access_token", + "session" + ], + "properties": { + "session": { + "description": "The created personal session", + "$ref": "#/components/schemas/PersonalSession" + }, + "access_token": { + "description": "The created personal access token", + "$ref": "#/components/schemas/PersonalAccessToken" + } + } + }, + "PersonalAccessToken": { + "description": "A personal access token", + "type": "object", + "required": [ + "created_at", + "session_id" + ], + "properties": { + "session_id": { + "description": "The ID of the personal session this token belongs to", + "$ref": "#/components/schemas/ULID" + }, + "created_at": { + "description": "When the token was created", + "type": "string", + "format": "date-time" + }, + "expires_at": { + "description": "When the token expires, if applicable", + "type": "string", + "format": "date-time", + "nullable": true + }, + "revoked_at": { + "description": "When the token was revoked, if applicable", + "type": "string", + "format": "date-time", + "nullable": true + }, + "access_token": { + "description": "The actual access token (only returned on creation)", + "type": "string", + "nullable": true + } + } + }, + "SingleResponse_for_PersonalSession": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_PersonalSession" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } + }, "SetPolicyDataRequest": { "title": "JSON payload for the `POST /api/admin/v1/policy-data`", "type": "object", From da9f68d6ea2157134e764b6f1f3dfccd100f1ae6 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Tue, 14 Oct 2025 11:35:52 +0100 Subject: [PATCH 3/5] drive-by clippy fixes --- crates/email/src/transport.rs | 7 ++----- crates/oauth2-types/src/requests.rs | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/email/src/transport.rs b/crates/email/src/transport.rs index 9161d76e5..4b31579d4 100644 --- a/crates/email/src/transport.rs +++ b/crates/email/src/transport.rs @@ -36,7 +36,9 @@ pub struct Transport { inner: Arc, } +#[derive(Default)] enum TransportInner { + #[default] Blackhole, Smtp(AsyncSmtpTransport), Sendmail(AsyncSendmailTransport), @@ -113,11 +115,6 @@ impl Transport { } } -impl Default for TransportInner { - fn default() -> Self { - Self::Blackhole - } -} #[derive(Debug, Error)] #[error(transparent)] diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index ac0770411..313330c2d 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -86,11 +86,13 @@ impl core::str::FromStr for ResponseMode { Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, SerializeDisplay, DeserializeFromStr, )] #[non_exhaustive] +#[derive(Default)] pub enum Display { /// The Authorization Server should display the authentication and consent /// UI consistent with a full User Agent page view. /// /// This is the default display mode. + #[default] Page, /// The Authorization Server should display the authentication and consent @@ -135,11 +137,6 @@ impl core::str::FromStr for Display { } } -impl Default for Display { - fn default() -> Self { - Self::Page - } -} /// Value that specifies whether the Authorization Server prompts the End-User /// for reauthentication and consent. From 3260f5154c739f77258e945652b4be80a4964c6a Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Tue, 14 Oct 2025 12:13:10 +0100 Subject: [PATCH 4/5] drive-by formatting fixes --- crates/email/src/transport.rs | 1 - crates/oauth2-types/src/requests.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/email/src/transport.rs b/crates/email/src/transport.rs index 4b31579d4..004844ab1 100644 --- a/crates/email/src/transport.rs +++ b/crates/email/src/transport.rs @@ -115,7 +115,6 @@ impl Transport { } } - #[derive(Debug, Error)] #[error(transparent)] pub enum Error { diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index 313330c2d..5a3921823 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -137,7 +137,6 @@ impl core::str::FromStr for Display { } } - /// Value that specifies whether the Authorization Server prompts the End-User /// for reauthentication and consent. /// From 5e5911cfa6c3d81dcb55e777cf6f85f9ea097e25 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Tue, 14 Oct 2025 12:31:26 +0100 Subject: [PATCH 5/5] drive-by update.sh chmod +x --- misc/update.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 misc/update.sh diff --git a/misc/update.sh b/misc/update.sh old mode 100644 new mode 100755