Personal Sessions: add create, list, get, revoke, regenerate Admin APIs (#5141)

Introduces some admin API endpoints for Personal Sessions.

- add: Creates a personal session along with its first personal access token, returning both. This is currently the only way to get a personal access token.
- get: Shows the information about a personal session
- list: Shows many personal sessions
- revoke: Revokes a personal session, so it can't be used anymore
- regenerate: Revoke the active personal access token for a session and issue a new one to replace it.
This commit is contained in:
reivilibre
2025-10-22 11:20:02 +01:00
committed by GitHub
46 changed files with 3075 additions and 109 deletions

3
Cargo.lock generated
View File

@@ -613,6 +613,7 @@ dependencies = [
"axum-core", "axum-core",
"bytes", "bytes",
"cookie", "cookie",
"form_urlencoded",
"futures-util", "futures-util",
"headers", "headers",
"http", "http",
@@ -622,6 +623,8 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
"serde_core", "serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",

View File

@@ -67,7 +67,7 @@ syn2mas = { path = "./crates/syn2mas", version = "=1.4.1" }
# OpenAPI schema generation and validation # OpenAPI schema generation and validation
[workspace.dependencies.aide] [workspace.dependencies.aide]
version = "0.14.2" version = "0.14.2"
features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"] features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"]
# An `Arc` that can be atomically updated # An `Arc` that can be atomically updated
[workspace.dependencies.arc-swap] [workspace.dependencies.arc-swap]
@@ -101,7 +101,7 @@ version = "0.8.6"
# Extra utilities for Axum # Extra utilities for Axum
[workspace.dependencies.axum-extra] [workspace.dependencies.axum-extra]
version = "0.10.3" version = "0.10.3"
features = ["cookie-private", "cookie-key-expansion", "typed-header"] features = ["cookie-private", "cookie-key-expansion", "typed-header", "query"]
# Axum macros # Axum macros
[workspace.dependencies.axum-macros] [workspace.dependencies.axum-macros]

View File

@@ -17,4 +17,6 @@ disallowed-methods = [
disallowed-types = [ disallowed-types = [
{ path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" }, { path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" },
{ path = "std::path::Path", reason = "use camino::Utf8Path instead" }, { path = "std::path::Path", reason = "use camino::Utf8Path instead" },
{ path = "axum::extract::Query", reason = "use axum_extra::extract::Query instead. The built-in version doesn't deserialise lists."},
{ path = "axum::extract::rejection::QueryRejection", reason = "use axum_extra::extract::QueryRejection instead"}
] ]

View File

@@ -36,7 +36,9 @@ pub struct Transport {
inner: Arc<TransportInner>, inner: Arc<TransportInner>,
} }
#[derive(Default)]
enum TransportInner { enum TransportInner {
#[default]
Blackhole, Blackhole,
Smtp(AsyncSmtpTransport<Tokio1Executor>), Smtp(AsyncSmtpTransport<Tokio1Executor>),
Sendmail(AsyncSendmailTransport<Tokio1Executor>), Sendmail(AsyncSendmailTransport<Tokio1Executor>),
@@ -113,12 +115,6 @@ impl Transport {
} }
} }
impl Default for TransportInner {
fn default() -> Self {
Self::Blackhole
}
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error(transparent)] #[error(transparent)]
pub enum Error { pub enum Error {

View File

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

View File

@@ -12,12 +12,10 @@ use std::{borrow::Cow, num::NonZeroUsize};
use aide::OperationIo; use aide::OperationIo;
use axum::{ use axum::{
Json, Json,
extract::{ extract::{FromRequestParts, Path, rejection::PathRejection},
FromRequestParts, Path, Query,
rejection::{PathRejection, QueryRejection},
},
response::IntoResponse, response::IntoResponse,
}; };
use axum_extra::extract::{Query, QueryRejection};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_storage::pagination::PaginationDirection; use mas_storage::pagination::PaginationDirection;

View File

@@ -4,11 +4,8 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation}; use aide::{OperationIo, transform::TransformOperation};
use axum::{ use axum::{Json, response::IntoResponse};
Json, use axum_extra::extract::{Query, QueryRejection};
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::record_error; use mas_axum_utils::record_error;

View File

@@ -20,6 +20,7 @@ use crate::passwords::PasswordManager;
mod compat_sessions; mod compat_sessions;
mod oauth2_sessions; mod oauth2_sessions;
mod personal_sessions;
mod policy_data; mod policy_data;
mod site_config; mod site_config;
mod upstream_oauth_links; mod upstream_oauth_links;
@@ -80,6 +81,38 @@ where
self::oauth2_sessions::finish_doc, 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(
"/personal-sessions/{id}/regenerate",
post_with(
self::personal_sessions::regenerate,
self::personal_sessions::regenerate_doc,
),
)
.api_route( .api_route(
"/policy-data", "/policy-data",
post_with(self::policy_data::set, self::policy_data::set_doc), post_with(self::policy_data::set, self::policy_data::set_doc),

View File

@@ -7,11 +7,8 @@
use std::str::FromStr; use std::str::FromStr;
use aide::{OperationIo, transform::TransformOperation}; use aide::{OperationIo, transform::TransformOperation};
use axum::{ use axum::{Json, response::IntoResponse};
Json, use axum_extra::extract::{Query, QueryRejection};
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::record_error; use mas_axum_utils::record_error;

View File

@@ -0,0 +1,281 @@
// 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;
use ulid::Ulid;
use crate::{
admin::{
call_context::CallContext,
model::{InconsistentPersonalSession, PersonalSession},
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};
#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("User not found")]
UserNotFound,
#[error("Invalid scope")]
InvalidScope,
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl_from_error_for_route!(InconsistentPersonalSession);
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<u32>,
}
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<SingleResponse<PersonalSession>>, _>(|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<BoxRng>,
Json(params): Json<Request>,
) -> Result<(StatusCode, Json<SingleResponse<PersonalSession>>), 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::from(exp_in))),
)
.await?;
repo.save().await?;
Ok((
StatusCode::CREATED,
Json(SingleResponse::new_canonical(
PersonalSession::try_from((session, Some(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#"
{
"data": {
"type": "personal-session",
"id": "01FSHN9AG07HNEZXNQM2KNBNF6",
"attributes": {
"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,
"expires_at": "2022-01-16T15:40:00Z",
"access_token": "mpt_FM44zJN5qePGMLvvMXC4Ds1A3lCWc6_bJ9Wj1"
},
"links": {
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
}
},
"links": {
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
}
}
"#);
}
#[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);
}
}

View File

@@ -0,0 +1,189 @@
// 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::{InconsistentPersonalSession, PersonalSession},
params::UlidPathParam,
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};
#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("Personal session not found")]
NotFound,
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl_from_error_for_route!(InconsistentPersonalSession);
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<SingleResponse<PersonalSession>>, _>(|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<Json<SingleResponse<PersonalSession>>, RouteError> {
let session_id = *id;
let session = repo
.personal_session()
.lookup(session_id)
.await?
.ok_or(RouteError::NotFound)?;
let token = if session.is_revoked() {
None
} else {
repo.personal_access_token()
.find_active_for_session(&session)
.await?
};
Ok(Json(SingleResponse::new_canonical(
PersonalSession::try_from((session, token))?,
)))
}
#[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.personal_access_token()
.add(&mut rng, &state.clock, &personal_session, "mpt_hiss", None)
.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,
"expires_at": 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);
}
}

View File

@@ -0,0 +1,585 @@
// 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 std::str::FromStr as _;
use aide::{OperationIo, transform::TransformOperation};
use axum::{Json, response::IntoResponse};
use axum_extra::extract::{Query, QueryRejection};
use axum_macros::FromRequestParts;
use chrono::{DateTime, Utc};
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_storage::personal::PersonalSessionFilter;
use oauth2_types::scope::{Scope, ScopeToken};
use schemars::JsonSchema;
use serde::Deserialize;
use ulid::Ulid;
use crate::{
admin::{
call_context::CallContext,
model::{InconsistentPersonalSession, 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<FilterParams>")]
#[from_request(via(Query), rejection(RouteError))]
pub struct FilterParams {
/// Filter by owner user ID
#[serde(rename = "filter[owner_user]")]
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
owner_user: Option<Ulid>,
/// Filter by owner `OAuth2` client ID
#[serde(rename = "filter[owner_client]")]
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
owner_client: Option<Ulid>,
/// Filter by actor user ID
#[serde(rename = "filter[actor_user]")]
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
actor_user: Option<Ulid>,
/// Retrieve the items with the given scope
#[serde(default, rename = "filter[scope]")]
scope: Vec<String>,
/// Filter by session status
#[serde(rename = "filter[status]")]
status: Option<PersonalSessionStatus>,
/// Filter by access token expiry date
#[serde(rename = "filter[expires_before]")]
expires_before: Option<DateTime<Utc>>,
/// Filter by access token expiry date
#[serde(rename = "filter[expires_after]")]
expires_after: Option<DateTime<Utc>>,
/// Filter by whether the access token has an expiry time
#[serde(rename = "filter[expires]")]
expires: Option<bool>,
}
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 = '&';
}
for scope in &self.scope {
write!(f, "{sep}filter[scope]={scope}")?;
sep = '&';
}
if let Some(status) = self.status {
write!(f, "{sep}filter[status]={status}")?;
sep = '&';
}
if let Some(expires_before) = self.expires_before {
write!(
f,
"{sep}filter[expires_before]={}",
expires_before.format("%Y-%m-%dT%H:%M:%SZ")
)?;
sep = '&';
}
if let Some(expires_after) = self.expires_after {
write!(
f,
"{sep}filter[expires_after]={}",
expires_after.format("%Y-%m-%dT%H:%M:%SZ")
)?;
sep = '&';
}
if let Some(expires) = self.expires {
write!(f, "{sep}filter[expires]={expires}")?;
sep = '&';
}
let _ = sep;
Ok(())
}
}
#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("User ID {0} not found")]
UserNotFound(Ulid),
#[error("Client ID {0} not found")]
ClientNotFound(Ulid),
#[error("Invalid filter parameters")]
InvalidFilter(#[from] QueryRejection),
#[error("Invalid scope {0:?} in filter parameters")]
InvalidScope(String),
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl_from_error_for_route!(InconsistentPersonalSession);
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::InvalidScope(_) | 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<PaginatedResponse<PersonalSession>>, _>(|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<Json<PaginatedResponse<PersonalSession>>, 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,
};
let scope: Scope = params
.scope
.into_iter()
.map(|s| ScopeToken::from_str(&s).map_err(|_| RouteError::InvalidScope(s)))
.collect::<Result<_, _>>()?;
let filter = if scope.is_empty() {
filter
} else {
filter.with_scope(&scope)
};
let filter = match params.status {
Some(PersonalSessionStatus::Active) => filter.active_only(),
Some(PersonalSessionStatus::Revoked) => filter.finished_only(),
None => filter,
};
let filter = if let Some(expires_after) = params.expires_after {
filter.with_expires_after(expires_after)
} else {
filter
};
let filter = if let Some(expires_before) = params.expires_before {
filter.with_expires_before(expires_before)
} else {
filter
};
let filter = if let Some(expires) = params.expires {
filter.with_expires(expires)
} else {
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.try_map(PersonalSession::try_from)?,
pagination,
Some(count),
&base,
)
}
IncludeCount::False => {
let page = repo.personal_session().list(filter, pagination).await?;
PaginatedResponse::for_page(
page.try_map(PersonalSession::try_from)?,
pagination,
None,
&base,
)
}
IncludeCount::Only => {
let count = repo.personal_session().count(filter).await?;
PaginatedResponse::for_count_only(count, &base)
}
};
Ok(Json(response))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use chrono::Duration;
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 crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_list(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool).await.unwrap();
// 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.personal_access_token()
.add(
&mut rng,
&state.clock,
&personal_session,
"mpt_hiss",
Some(Duration::days(42)),
)
.await
.unwrap();
state.clock.advance(Duration::days(1));
let personal_session = repo
.personal_session()
.add(
&mut rng,
&state.clock,
PersonalSessionOwner::from(&user),
&user,
"Another test session".to_owned(),
Scope::from_iter([OPENID]),
)
.await
.unwrap();
repo.personal_access_token()
.add(
&mut rng,
&state.clock,
&personal_session,
"mpt_scratch",
Some(Duration::days(21)),
)
.await
.unwrap();
repo.personal_session()
.revoke(&state.clock, personal_session)
.await
.unwrap();
state.clock.advance(Duration::days(1));
let personal_session = repo
.personal_session()
.add(
&mut rng,
&state.clock,
PersonalSessionOwner::from(&user),
&user,
"Another test session".to_owned(),
Scope::from_iter([OPENID, "urn:mas:admin".parse().unwrap()]),
)
.await
.unwrap();
repo.personal_access_token()
.add(
&mut rng,
&state.clock,
&personal_session,
"mpt_meow",
Some(Duration::days(14)),
)
.await
.unwrap();
repo.save().await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;
let request = Request::get("/api/admin/v1/personal-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": 3
},
"data": [
{
"type": "personal-session",
"id": "01FSHN9AG0YQYAR04VCYTHJ8SK",
"attributes": {
"created_at": "2022-01-16T14:40:00Z",
"revoked_at": null,
"owner_user_id": "01FSHN9AG09FE39KETP6F390F8",
"owner_client_id": null,
"actor_user_id": "01FSHN9AG09FE39KETP6F390F8",
"human_name": "Test session",
"scope": "openid",
"last_active_at": null,
"last_active_ip": null,
"expires_at": "2022-02-27T14:40:00Z"
},
"links": {
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0YQYAR04VCYTHJ8SK"
},
"meta": {
"page": {
"cursor": "01FSHN9AG0YQYAR04VCYTHJ8SK"
}
}
},
{
"type": "personal-session",
"id": "01FSM7P1G0VBGAMK9D9QMGQ5MY",
"attributes": {
"created_at": "2022-01-17T14:40:00Z",
"revoked_at": "2022-01-17T14:40:00Z",
"owner_user_id": "01FSHN9AG09FE39KETP6F390F8",
"owner_client_id": null,
"actor_user_id": "01FSHN9AG09FE39KETP6F390F8",
"human_name": "Another test session",
"scope": "openid",
"last_active_at": null,
"last_active_ip": null,
"expires_at": null
},
"links": {
"self": "/api/admin/v1/personal-sessions/01FSM7P1G0VBGAMK9D9QMGQ5MY"
},
"meta": {
"page": {
"cursor": "01FSM7P1G0VBGAMK9D9QMGQ5MY"
}
}
},
{
"type": "personal-session",
"id": "01FSPT2RG08Y11Y5BM4VZ4CN8K",
"attributes": {
"created_at": "2022-01-18T14:40:00Z",
"revoked_at": null,
"owner_user_id": "01FSHN9AG09FE39KETP6F390F8",
"owner_client_id": null,
"actor_user_id": "01FSHN9AG09FE39KETP6F390F8",
"human_name": "Another test session",
"scope": "openid urn:mas:admin",
"last_active_at": null,
"last_active_ip": null,
"expires_at": "2022-02-01T14:40:00Z"
},
"links": {
"self": "/api/admin/v1/personal-sessions/01FSPT2RG08Y11Y5BM4VZ4CN8K"
},
"meta": {
"page": {
"cursor": "01FSPT2RG08Y11Y5BM4VZ4CN8K"
}
}
}
],
"links": {
"self": "/api/admin/v1/personal-sessions?page[first]=10",
"first": "/api/admin/v1/personal-sessions?page[first]=10",
"last": "/api/admin/v1/personal-sessions?page[last]=10"
}
}
"#);
// Map of filters to their expected set of returned ULIDs
let filters_and_expected: &[(&str, &[&str])] = &[
(
"filter[expires_before]=2022-02-15T00:00:00Z",
&["01FSPT2RG08Y11Y5BM4VZ4CN8K"],
),
(
"filter[expires_after]=2022-02-15T00:00:00Z",
&["01FSHN9AG0YQYAR04VCYTHJ8SK"],
),
(
"filter[status]=active",
&["01FSHN9AG0YQYAR04VCYTHJ8SK", "01FSPT2RG08Y11Y5BM4VZ4CN8K"],
),
("filter[status]=revoked", &["01FSM7P1G0VBGAMK9D9QMGQ5MY"]),
(
"filter[expires]=true",
&["01FSHN9AG0YQYAR04VCYTHJ8SK", "01FSPT2RG08Y11Y5BM4VZ4CN8K"],
),
("filter[expires]=false", &["01FSM7P1G0VBGAMK9D9QMGQ5MY"]),
(
"filter[scope]=urn:mas:admin",
&["01FSPT2RG08Y11Y5BM4VZ4CN8K"],
),
];
for (filter, expected_ids) in filters_and_expected {
let request = Request::get(format!("/api/admin/v1/personal-sessions?{filter}"))
.bearer(&token)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
let found: BTreeSet<&str> = body["data"]
.as_array()
.unwrap()
.iter()
.map(|item| item["id"].as_str().unwrap())
.collect();
let expected: BTreeSet<&str> = expected_ids.iter().copied().collect();
assert_eq!(
found, expected,
"filter {filter} did not produce expected results"
);
}
}
}

View File

@@ -0,0 +1,18 @@
// 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 regenerate;
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},
regenerate::{doc as regenerate_doc, handler as regenerate},
revoke::{doc as revoke_doc, handler as revoke},
};

View File

@@ -0,0 +1,249 @@
// 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 schemars::JsonSchema;
use serde::Deserialize;
use tracing::error;
use crate::{
admin::{
call_context::CallContext,
model::{InconsistentPersonalSession, PersonalSession},
params::UlidPathParam,
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};
#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("User not found")]
UserNotFound,
#[error("Session not found")]
SessionNotFound,
#[error("Session not valid")]
SessionNotValid,
#[error("Session does not belong to you")]
SessionNotYours,
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl_from_error_for_route!(InconsistentPersonalSession);
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::SessionNotFound => StatusCode::NOT_FOUND,
Self::SessionNotValid => StatusCode::UNPROCESSABLE_ENTITY,
Self::SessionNotYours => StatusCode::FORBIDDEN,
};
(status, sentry_event_id, Json(error)).into_response()
}
}
/// # JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint
#[derive(Deserialize, JsonSchema)]
#[serde(rename = "RegeneratePersonalSessionRequest")]
pub struct Request {
/// Token expiry time in seconds.
/// If not set, the token won't expire.
expires_in: Option<u32>,
}
pub fn doc(operation: TransformOperation) -> TransformOperation {
operation
.id("regeneratePersonalSession")
.summary("Regenerate a personal session by replacing its personal access token")
.tag("personal-session")
.response_with::<201, Json<SingleResponse<PersonalSession>>, _>(|t| {
t.description(
"Personal session was regenerated and a personal access token was created",
)
})
.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: caller_session,
..
}: CallContext,
NoApi(mut rng): NoApi<BoxRng>,
id: UlidPathParam,
Json(params): Json<Request>,
) -> Result<(StatusCode, Json<SingleResponse<PersonalSession>>), RouteError> {
let session_id = *id;
let session = repo
.personal_session()
.lookup(session_id)
.await?
.ok_or(RouteError::SessionNotFound)?;
if !session.is_valid() {
// We don't revive revoked sessions through regeneration
return Err(RouteError::SessionNotValid);
}
// If the owner is not the current caller, then currently we reject the
// regeneration.
let caller = if let Some(user_id) = caller_session.user_id {
PersonalSessionOwner::User(user_id)
} else {
PersonalSessionOwner::OAuth2Client(caller_session.client_id)
};
if session.owner != caller {
return Err(RouteError::SessionNotYours);
}
// Revoke the existing active token for the session.
let old_token_opt = repo
.personal_access_token()
.find_active_for_session(&session)
.await?;
let Some(old_token) = old_token_opt else {
// This shouldn't happen
error!("session is supposedly valid but had no access token");
return Err(RouteError::SessionNotValid);
};
repo.personal_access_token()
.revoke(&clock, old_token)
.await?;
// Create the regenerated 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::from(exp_in))),
)
.await?;
repo.save().await?;
Ok((
StatusCode::CREATED,
Json(SingleResponse::new_canonical(
PersonalSession::try_from((session, Some(access_token)))?
.with_token(access_token_string),
)),
))
}
#[cfg(test)]
mod tests {
use chrono::Duration;
use hyper::{Request, StatusCode};
use insta::assert_json_snapshot;
use serde_json::{Value, json};
use sqlx::PgPool;
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_regenerate_personal_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 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 = Request::post("/api/admin/v1/personal-sessions")
.bearer(&token)
.json(json!({
"actor_user_id": user.id,
"human_name": "SuperDuperAdminCLITool Token",
"scope": "openid urn:mas:admin",
"expires_in": 3600
}));
let response = state.request(request).await;
response.assert_status(StatusCode::CREATED);
let created: Value = response.json();
let session_id = created["data"]["id"].as_str().unwrap();
state.clock.advance(Duration::minutes(3));
let request = Request::post(format!(
"/api/admin/v1/personal-sessions/{session_id}/regenerate"
))
.bearer(&token)
.json(json!({
"expires_in": 86400
}));
let response = state.request(request).await;
response.assert_status(StatusCode::CREATED);
let body: Value = response.json();
assert_json_snapshot!(body, @r#"
{
"data": {
"type": "personal-session",
"id": "01FSHN9AG07HNEZXNQM2KNBNF6",
"attributes": {
"created_at": "2022-01-16T14:40:00Z",
"revoked_at": null,
"owner_user_id": null,
"owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR",
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
"human_name": "SuperDuperAdminCLITool Token",
"scope": "openid urn:mas:admin",
"last_active_at": null,
"last_active_ip": null,
"expires_at": "2022-01-17T14:43:00Z",
"access_token": "mpt_6cq7FqNSYoosbXl3bbpfh9yNy9NzuR_0vOV2O"
},
"links": {
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
}
},
"links": {
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
}
}
"#);
}
}

View File

@@ -0,0 +1,235 @@
// 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::{InconsistentPersonalSession, PersonalSession},
params::UlidPathParam,
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};
#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[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_from_error_for_route!(InconsistentPersonalSession);
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<SingleResponse<PersonalSession>>, _>(|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<Json<SingleResponse<PersonalSession>>, 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::try_from((session, None))?,
)))
}
#[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"
);
}
}

View File

@@ -4,11 +4,8 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation}; use aide::{OperationIo, transform::TransformOperation};
use axum::{ use axum::{Json, response::IntoResponse};
Json, use axum_extra::extract::{Query, QueryRejection};
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::record_error; use mas_axum_utils::record_error;

View File

@@ -4,11 +4,8 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation}; use aide::{OperationIo, transform::TransformOperation};
use axum::{ use axum::{Json, response::IntoResponse};
Json, use axum_extra::extract::{Query, QueryRejection};
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::record_error; use mas_axum_utils::record_error;

View File

@@ -4,11 +4,8 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation}; use aide::{OperationIo, transform::TransformOperation};
use axum::{ use axum::{Json, response::IntoResponse};
Json, use axum_extra::extract::{Query, QueryRejection};
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::record_error; use mas_axum_utils::record_error;

View File

@@ -5,11 +5,8 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation}; use aide::{OperationIo, transform::TransformOperation};
use axum::{ use axum::{Json, response::IntoResponse};
Json, use axum_extra::extract::{Query, QueryRejection};
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::record_error; use mas_axum_utils::record_error;

View File

@@ -4,11 +4,8 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation}; use aide::{OperationIo, transform::TransformOperation};
use axum::{ use axum::{Json, response::IntoResponse};
Json, use axum_extra::extract::{Query, QueryRejection};
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::record_error; use mas_axum_utils::record_error;

View File

@@ -5,11 +5,8 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation}; use aide::{OperationIo, transform::TransformOperation};
use axum::{ use axum::{Json, response::IntoResponse};
Json, use axum_extra::extract::{Query, QueryRejection};
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts; use axum_macros::FromRequestParts;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::record_error; use mas_axum_utils::record_error;

View File

@@ -8,9 +8,10 @@ use std::collections::HashMap;
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
extract::{Form, Path, Query, State}, extract::{Form, Path, State},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use axum_extra::extract::Query;
use chrono::Duration; use chrono::Duration;
use mas_axum_utils::{ use mas_axum_utils::{
InternalError, InternalError,

View File

@@ -4,10 +4,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use axum::{ use axum::{extract::State, response::IntoResponse};
extract::{Query, State}, use axum_extra::extract::Query;
response::IntoResponse,
};
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::{GenericError, InternalError}; use mas_axum_utils::{GenericError, InternalError};
use mas_data_model::{BoxClock, BoxRng}; use mas_data_model::{BoxClock, BoxRng};

View File

@@ -5,9 +5,10 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use axum::{ use axum::{
extract::{Query, State}, extract::State,
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use axum_extra::extract::Query;
use mas_axum_utils::{InternalError, cookies::CookieJar}; use mas_axum_utils::{InternalError, cookies::CookieJar};
use mas_data_model::BoxClock; use mas_data_model::BoxClock;
use mas_router::UrlBuilder; use mas_router::UrlBuilder;

View File

@@ -4,12 +4,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use axum::{ use axum::{Json, extract::State, response::IntoResponse};
Json, use axum_extra::{extract::Query, typed_header::TypedHeader};
extract::{Query, State},
response::IntoResponse,
};
use axum_extra::typed_header::TypedHeader;
use headers::ContentType; use headers::ContentType;
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use oauth2_types::webfinger::WebFingerResponse; use oauth2_types::webfinger::WebFingerResponse;

View File

@@ -5,9 +5,10 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, State},
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
}; };
use axum_extra::extract::Query;
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar}; use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar};
use mas_data_model::{BoxClock, BoxRng, UpstreamOAuthProvider}; use mas_data_model::{BoxClock, BoxRng, UpstreamOAuthProvider};

View File

@@ -5,9 +5,10 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use axum::{ use axum::{
extract::{Query, State}, extract::State,
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use axum_extra::extract::Query;
use mas_axum_utils::{InternalError, cookies::CookieJar}; use mas_axum_utils::{InternalError, cookies::CookieJar};
use mas_data_model::{BoxClock, BoxRng}; use mas_data_model::{BoxClock, BoxRng};
use mas_router::{PostAuthAction, UrlBuilder}; use mas_router::{PostAuthAction, UrlBuilder};

View File

@@ -7,10 +7,10 @@
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use axum::{ use axum::{
extract::{Form, Query, State}, extract::{Form, State},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::typed_header::TypedHeader; use axum_extra::{extract::Query, typed_header::TypedHeader};
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::{ use mas_axum_utils::{
InternalError, SessionInfoExt, InternalError, SessionInfoExt,

View File

@@ -4,9 +4,10 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use axum::{ use axum::{
extract::{Query, State}, extract::State,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::extract::Query;
use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _}; use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _};
use mas_data_model::{BoxClock, BoxRng, SiteConfig}; use mas_data_model::{BoxClock, BoxRng, SiteConfig};
use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder};

View File

@@ -7,10 +7,10 @@
use std::{str::FromStr, sync::Arc}; use std::{str::FromStr, sync::Arc};
use axum::{ use axum::{
extract::{Form, Query, State}, extract::{Form, State},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::typed_header::TypedHeader; use axum_extra::{extract::Query, typed_header::TypedHeader};
use hyper::StatusCode; use hyper::StatusCode;
use lettre::Address; use lettre::Address;
use mas_axum_utils::{ use mas_axum_utils::{

View File

@@ -86,11 +86,13 @@ impl core::str::FromStr for ResponseMode {
Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, SerializeDisplay, DeserializeFromStr, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, SerializeDisplay, DeserializeFromStr,
)] )]
#[non_exhaustive] #[non_exhaustive]
#[derive(Default)]
pub enum Display { pub enum Display {
/// The Authorization Server should display the authentication and consent /// The Authorization Server should display the authentication and consent
/// UI consistent with a full User Agent page view. /// UI consistent with a full User Agent page view.
/// ///
/// This is the default display mode. /// This is the default display mode.
#[default]
Page, Page,
/// The Authorization Server should display the authentication and consent /// The Authorization Server should display the authentication and consent
@@ -135,12 +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 /// Value that specifies whether the Authorization Server prompts the End-User
/// for reauthentication and consent. /// for reauthentication and consent.
/// ///

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM personal_access_tokens\n WHERE personal_session_id IN (\n SELECT personal_session_id\n FROM personal_sessions\n WHERE owner_oauth2_client_id = $1\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE personal_access_tokens\n SET revoked_at = $2\n WHERE personal_session_id = $1 AND revoked_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Timestamptz"
]
},
"nullable": []
},
"hash": "9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292"
}

View File

@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE personal_session_id = $1\n AND revoked_at IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "personal_access_token_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "personal_session_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "revoked_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM personal_sessions\n WHERE owner_oauth2_client_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e"
}

View File

@@ -23,7 +23,7 @@
"Left": [] "Left": []
}, },
"nullable": [ "nullable": [
false, true,
true, true,
null null
] ]

View File

@@ -125,6 +125,18 @@ pub enum PersonalSessions {
LastActiveIp, LastActiveIp,
} }
#[derive(sea_query::Iden)]
#[iden = "personal_access_tokens"]
pub enum PersonalAccessTokens {
Table,
PersonalAccessTokenId,
PersonalSessionId,
// AccessTokenSha256,
CreatedAt,
ExpiresAt,
RevokedAt,
}
#[derive(sea_query::Iden)] #[derive(sea_query::Iden)]
#[iden = "upstream_oauth_providers"] #[iden = "upstream_oauth_providers"]
pub enum UpstreamOAuthProviders { pub enum UpstreamOAuthProviders {

View File

@@ -811,6 +811,49 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> {
.await?; .await?;
} }
// Delete any personal access tokens & sessions owned
// by the client
{
let span = info_span!(
"db.oauth2_client.delete_by_id.personal_access_tokens",
{ DB_QUERY_TEXT } = tracing::field::Empty,
);
sqlx::query!(
r#"
DELETE FROM personal_access_tokens
WHERE personal_session_id IN (
SELECT personal_session_id
FROM personal_sessions
WHERE owner_oauth2_client_id = $1
)
"#,
Uuid::from(id),
)
.record(&span)
.execute(&mut *self.conn)
.instrument(span)
.await?;
}
{
let span = info_span!(
"db.oauth2_client.delete_by_id.personal_sessions",
{ DB_QUERY_TEXT } = tracing::field::Empty,
);
sqlx::query!(
r#"
DELETE FROM personal_sessions
WHERE owner_oauth2_client_id = $1
"#,
Uuid::from(id),
)
.record(&span)
.execute(&mut *self.conn)
.instrument(span)
.await?;
}
// Now delete the client itself // Now delete the client itself
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"

View File

@@ -128,6 +128,43 @@ impl PersonalAccessTokenRepository for PgPersonalAccessTokenRepository<'_> {
Ok(Some(res.into())) Ok(Some(res.into()))
} }
#[tracing::instrument(
name = "db.personal_access_token.find_active_for_session",
skip_all,
fields(
db.query.text,
),
err,
)]
async fn find_active_for_session(
&mut self,
session: &PersonalSession,
) -> Result<Option<PersonalAccessToken>, Self::Error> {
let res: Option<PersonalAccessTokenLookup> = sqlx::query_as!(
PersonalAccessTokenLookup,
r#"
SELECT personal_access_token_id
, personal_session_id
, created_at
, expires_at
, revoked_at
FROM personal_access_tokens
WHERE personal_session_id = $1
AND revoked_at IS NULL
"#,
Uuid::from(session.id),
)
.traced()
.fetch_optional(&mut *self.conn)
.await?;
let Some(res) = res else { return Ok(None) };
Ok(Some(res.into()))
}
#[tracing::instrument( #[tracing::instrument(
name = "db.personal_access_token.add", name = "db.personal_access_token.add",
skip_all, skip_all,

View File

@@ -105,16 +105,16 @@ mod tests {
let full_list = repo.personal_session().list(all, pagination).await.unwrap(); let full_list = repo.personal_session().list(all, pagination).await.unwrap();
assert_eq!(full_list.edges.len(), 1); assert_eq!(full_list.edges.len(), 1);
assert_eq!(full_list.edges[0].node.id, session.id); assert_eq!(full_list.edges[0].node.0.id, session.id);
assert!(full_list.edges[0].node.is_valid()); assert!(full_list.edges[0].node.0.is_valid());
let active_list = repo let active_list = repo
.personal_session() .personal_session()
.list(active, pagination) .list(active, pagination)
.await .await
.unwrap(); .unwrap();
assert_eq!(active_list.edges.len(), 1); assert_eq!(active_list.edges.len(), 1);
assert_eq!(active_list.edges[0].node.id, session.id); assert_eq!(active_list.edges[0].node.0.id, session.id);
assert!(active_list.edges[0].node.is_valid()); assert!(active_list.edges[0].node.0.is_valid());
let finished_list = repo let finished_list = repo
.personal_session() .personal_session()
.list(finished, pagination) .list(finished, pagination)
@@ -154,7 +154,7 @@ mod tests {
let full_list = repo.personal_session().list(all, pagination).await.unwrap(); let full_list = repo.personal_session().list(all, pagination).await.unwrap();
assert_eq!(full_list.edges.len(), 1); assert_eq!(full_list.edges.len(), 1);
assert_eq!(full_list.edges[0].node.id, session.id); assert_eq!(full_list.edges[0].node.0.id, session.id);
let active_list = repo let active_list = repo
.personal_session() .personal_session()
.list(active, pagination) .list(active, pagination)
@@ -167,8 +167,8 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(finished_list.edges.len(), 1); assert_eq!(finished_list.edges.len(), 1);
assert_eq!(finished_list.edges[0].node.id, session.id); assert_eq!(finished_list.edges[0].node.0.id, session.id);
assert!(finished_list.edges[0].node.is_revoked()); assert!(finished_list.edges[0].node.0.is_revoked());
// Reload the session and check again // Reload the session and check again
let session_lookup = repo let session_lookup = repo

View File

@@ -9,7 +9,10 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use mas_data_model::{ use mas_data_model::{
Clock, User, Clock, User,
personal::session::{PersonalSession, PersonalSessionOwner, SessionState}, personal::{
PersonalAccessToken,
session::{PersonalSession, PersonalSessionOwner, SessionState},
},
}; };
use mas_storage::{ use mas_storage::{
Page, Pagination, Page, Pagination,
@@ -17,13 +20,15 @@ use mas_storage::{
personal::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState}, personal::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState},
}; };
use oauth2_types::scope::Scope; use oauth2_types::scope::Scope;
use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT;
use rand::RngCore; use rand::RngCore;
use sea_query::{ use sea_query::{
Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def, Cond, Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def,
extension::postgres::PgExpr as _, extension::postgres::PgExpr as _,
}; };
use sea_query_binder::SqlxBinder as _; use sea_query_binder::SqlxBinder as _;
use sqlx::PgConnection; use sqlx::PgConnection;
use tracing::{Instrument as _, info_span};
use ulid::Ulid; use ulid::Ulid;
use uuid::Uuid; use uuid::Uuid;
@@ -31,7 +36,7 @@ use crate::{
DatabaseError, DatabaseError,
errors::DatabaseInconsistencyError, errors::DatabaseInconsistencyError,
filter::{Filter, StatementExt as _}, filter::{Filter, StatementExt as _},
iden::PersonalSessions, iden::{PersonalAccessTokens, PersonalSessions},
pagination::QueryBuilderExt as _, pagination::QueryBuilderExt as _,
tracing::ExecuteExt as _, tracing::ExecuteExt as _,
}; };
@@ -116,6 +121,73 @@ impl TryFrom<PersonalSessionLookup> for PersonalSession {
} }
} }
#[derive(sqlx::FromRow)]
#[enum_def]
struct PersonalSessionAndAccessTokenLookup {
personal_session_id: Uuid,
owner_user_id: Option<Uuid>,
owner_oauth2_client_id: Option<Uuid>,
actor_user_id: Uuid,
human_name: String,
scope_list: Vec<String>,
created_at: DateTime<Utc>,
revoked_at: Option<DateTime<Utc>>,
last_active_at: Option<DateTime<Utc>>,
last_active_ip: Option<IpAddr>,
// tokens
personal_access_token_id: Option<Uuid>,
token_created_at: Option<DateTime<Utc>>,
token_expires_at: Option<DateTime<Utc>>,
}
impl Node<Ulid> for PersonalSessionAndAccessTokenLookup {
fn cursor(&self) -> Ulid {
self.personal_session_id.into()
}
}
impl TryFrom<PersonalSessionAndAccessTokenLookup>
for (PersonalSession, Option<PersonalAccessToken>)
{
type Error = DatabaseInconsistencyError;
fn try_from(value: PersonalSessionAndAccessTokenLookup) -> Result<Self, Self::Error> {
let session = PersonalSession::try_from(PersonalSessionLookup {
personal_session_id: value.personal_session_id,
owner_user_id: value.owner_user_id,
owner_oauth2_client_id: value.owner_oauth2_client_id,
actor_user_id: value.actor_user_id,
human_name: value.human_name,
scope_list: value.scope_list,
created_at: value.created_at,
revoked_at: value.revoked_at,
last_active_at: value.last_active_at,
last_active_ip: value.last_active_ip,
})?;
let token_opt = if let Some(id) = value.personal_access_token_id {
let id = Ulid::from(id);
Some(PersonalAccessToken {
id,
session_id: session.id,
// should not be possible
created_at: value.token_created_at.ok_or(
DatabaseInconsistencyError::on("personal_sessions")
.column("created_at")
.row(id),
)?,
expires_at: value.token_expires_at,
revoked_at: None,
})
} else {
None
};
Ok((session, token_opt))
}
}
#[async_trait] #[async_trait]
impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
type Error = DatabaseError; type Error = DatabaseError;
@@ -241,7 +313,30 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
clock: &dyn Clock, clock: &dyn Clock,
session: PersonalSession, session: PersonalSession,
) -> Result<PersonalSession, Self::Error> { ) -> Result<PersonalSession, Self::Error> {
let finished_at = clock.now(); let revoked_at = clock.now();
{
// Revoke dependent PATs
let span = info_span!(
"db.personal_session.revoke.tokens",
{ DB_QUERY_TEXT } = tracing::field::Empty,
);
sqlx::query!(
r#"
UPDATE personal_access_tokens
SET revoked_at = $2
WHERE personal_session_id = $1 AND revoked_at IS NULL
"#,
Uuid::from(session.id),
revoked_at,
)
.record(&span)
.execute(&mut *self.conn)
.instrument(span)
.await?;
}
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
UPDATE personal_sessions UPDATE personal_sessions
@@ -249,7 +344,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
WHERE personal_session_id = $1 WHERE personal_session_id = $1
"#, "#,
Uuid::from(session.id), Uuid::from(session.id),
finished_at, revoked_at,
) )
.traced() .traced()
.execute(&mut *self.conn) .execute(&mut *self.conn)
@@ -258,7 +353,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
DatabaseError::ensure_affected_rows(&res, 1)?; DatabaseError::ensure_affected_rows(&res, 1)?;
session session
.finish(finished_at) .finish(revoked_at)
.map_err(DatabaseError::to_invalid_operation) .map_err(DatabaseError::to_invalid_operation)
} }
@@ -274,52 +369,82 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
&mut self, &mut self,
filter: PersonalSessionFilter<'_>, filter: PersonalSessionFilter<'_>,
pagination: Pagination, pagination: Pagination,
) -> Result<Page<PersonalSession>, Self::Error> { ) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error> {
let (sql, arguments) = Query::select() let (sql, arguments) = Query::select()
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)), Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)),
PersonalSessionLookupIden::PersonalSessionId, PersonalSessionAndAccessTokenLookupIden::PersonalSessionId,
) )
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)), Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)),
PersonalSessionLookupIden::OwnerUserId, PersonalSessionAndAccessTokenLookupIden::OwnerUserId,
) )
.expr_as( .expr_as(
Expr::col(( Expr::col((
PersonalSessions::Table, PersonalSessions::Table,
PersonalSessions::OwnerOAuth2ClientId, PersonalSessions::OwnerOAuth2ClientId,
)), )),
PersonalSessionLookupIden::OwnerOauth2ClientId, PersonalSessionAndAccessTokenLookupIden::OwnerOauth2ClientId,
) )
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)), Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)),
PersonalSessionLookupIden::ActorUserId, PersonalSessionAndAccessTokenLookupIden::ActorUserId,
) )
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::HumanName)), Expr::col((PersonalSessions::Table, PersonalSessions::HumanName)),
PersonalSessionLookupIden::HumanName, PersonalSessionAndAccessTokenLookupIden::HumanName,
) )
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)), Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)),
PersonalSessionLookupIden::ScopeList, PersonalSessionAndAccessTokenLookupIden::ScopeList,
) )
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::CreatedAt)), Expr::col((PersonalSessions::Table, PersonalSessions::CreatedAt)),
PersonalSessionLookupIden::CreatedAt, PersonalSessionAndAccessTokenLookupIden::CreatedAt,
) )
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)), Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)),
PersonalSessionLookupIden::RevokedAt, PersonalSessionAndAccessTokenLookupIden::RevokedAt,
) )
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)), Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)),
PersonalSessionLookupIden::LastActiveAt, PersonalSessionAndAccessTokenLookupIden::LastActiveAt,
) )
.expr_as( .expr_as(
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveIp)), Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveIp)),
PersonalSessionLookupIden::LastActiveIp, PersonalSessionAndAccessTokenLookupIden::LastActiveIp,
)
.expr_as(
Expr::col((
PersonalAccessTokens::Table,
PersonalAccessTokens::PersonalAccessTokenId,
)),
PersonalSessionAndAccessTokenLookupIden::PersonalAccessTokenId,
)
.expr_as(
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::CreatedAt)),
PersonalSessionAndAccessTokenLookupIden::TokenCreatedAt,
)
.expr_as(
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)),
PersonalSessionAndAccessTokenLookupIden::TokenExpiresAt,
) )
.from(PersonalSessions::Table) .from(PersonalSessions::Table)
.left_join(
PersonalAccessTokens::Table,
Cond::all()
.add(
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId))
.eq(Expr::col((
PersonalAccessTokens::Table,
PersonalAccessTokens::PersonalSessionId,
))),
)
.add(
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt))
.is_null(),
),
)
.apply_filter(filter) .apply_filter(filter)
.generate_pagination( .generate_pagination(
(PersonalSessions::Table, PersonalSessions::PersonalSessionId), (PersonalSessions::Table, PersonalSessions::PersonalSessionId),
@@ -327,7 +452,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
) )
.build_sqlx(PostgresQueryBuilder); .build_sqlx(PostgresQueryBuilder);
let edges: Vec<PersonalSessionLookup> = sqlx::query_as_with(&sql, arguments) let edges: Vec<PersonalSessionAndAccessTokenLookup> = sqlx::query_as_with(&sql, arguments)
.traced() .traced()
.fetch_all(&mut *self.conn) .fetch_all(&mut *self.conn)
.await?; .await?;
@@ -349,6 +474,21 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
let (sql, arguments) = Query::select() let (sql, arguments) = Query::select()
.expr(Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)).count()) .expr(Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)).count())
.from(PersonalSessions::Table) .from(PersonalSessions::Table)
.left_join(
PersonalAccessTokens::Table,
Cond::all()
.add(
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId))
.eq(Expr::col((
PersonalAccessTokens::Table,
PersonalAccessTokens::PersonalSessionId,
))),
)
.add(
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt))
.is_null(),
),
)
.apply_filter(filter) .apply_filter(filter)
.build_sqlx(PostgresQueryBuilder); .build_sqlx(PostgresQueryBuilder);
@@ -469,5 +609,23 @@ impl Filter for PersonalSessionFilter<'_> {
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)) Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt))
.gt(last_active_after) .gt(last_active_after)
})) }))
.add_option(self.expires_before().map(|expires_before| {
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt))
.lt(expires_before)
}))
.add_option(self.expires_after().map(|expires_after| {
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt))
.gt(expires_after)
}))
.add_option(self.expires().map(|expires| {
let column =
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt));
if expires {
column.is_not_null()
} else {
column.is_null()
}
}))
} }
} }

View File

@@ -50,6 +50,22 @@ pub trait PersonalAccessTokenRepository: Send + Sync {
access_token: &str, access_token: &str,
) -> Result<Option<PersonalAccessToken>, Self::Error>; ) -> Result<Option<PersonalAccessToken>, Self::Error>;
/// Find the active access token belonging to a given session.
///
/// Returns the active access token if it exists, `None` otherwise
///
/// # Parameters
///
/// * `session`: The session to lookup
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn find_active_for_session(
&mut self,
session: &PersonalSession,
) -> Result<Option<PersonalAccessToken>, Self::Error>;
/// Add a new access token to the database /// Add a new access token to the database
/// ///
/// Returns the newly created access token /// Returns the newly created access token
@@ -102,6 +118,11 @@ repository_impl!(PersonalAccessTokenRepository:
access_token: &str, access_token: &str,
) -> Result<Option<PersonalAccessToken>, Self::Error>; ) -> Result<Option<PersonalAccessToken>, Self::Error>;
async fn find_active_for_session(
&mut self,
session: &PersonalSession,
) -> Result<Option<PersonalAccessToken>, Self::Error>;
async fn add( async fn add(
&mut self, &mut self,
rng: &mut (dyn RngCore + Send), rng: &mut (dyn RngCore + Send),

View File

@@ -9,7 +9,10 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use mas_data_model::{ use mas_data_model::{
Client, Clock, Device, User, Client, Clock, Device, User,
personal::session::{PersonalSession, PersonalSessionOwner}, personal::{
PersonalAccessToken,
session::{PersonalSession, PersonalSessionOwner},
},
}; };
use oauth2_types::scope::Scope; use oauth2_types::scope::Scope;
use rand_core::RngCore; use rand_core::RngCore;
@@ -99,7 +102,7 @@ pub trait PersonalSessionRepository: Send + Sync {
&mut self, &mut self,
filter: PersonalSessionFilter<'_>, filter: PersonalSessionFilter<'_>,
pagination: Pagination, pagination: Pagination,
) -> Result<Page<PersonalSession>, Self::Error>; ) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error>;
/// Count [`PersonalSession`]s matching the given filter /// Count [`PersonalSession`]s matching the given filter
/// ///
@@ -151,7 +154,7 @@ repository_impl!(PersonalSessionRepository:
&mut self, &mut self,
filter: PersonalSessionFilter<'_>, filter: PersonalSessionFilter<'_>,
pagination: Pagination, pagination: Pagination,
) -> Result<Page<PersonalSession>, Self::Error>; ) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error>;
async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result<usize, Self::Error>; async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result<usize, Self::Error>;
@@ -161,7 +164,8 @@ repository_impl!(PersonalSessionRepository:
) -> Result<(), Self::Error>; ) -> Result<(), Self::Error>;
); );
/// Filter parameters for listing personal sessions /// Filter parameters for listing personal sessions alongside personal access
/// tokens
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct PersonalSessionFilter<'a> { pub struct PersonalSessionFilter<'a> {
owner_user: Option<&'a User>, owner_user: Option<&'a User>,
@@ -172,6 +176,9 @@ pub struct PersonalSessionFilter<'a> {
scope: Option<&'a Scope>, scope: Option<&'a Scope>,
last_active_before: Option<DateTime<Utc>>, last_active_before: Option<DateTime<Utc>>,
last_active_after: Option<DateTime<Utc>>, last_active_after: Option<DateTime<Utc>>,
expires_before: Option<DateTime<Utc>>,
expires_after: Option<DateTime<Utc>>,
expires: Option<bool>,
} }
/// Filter for what state a personal session is in. /// Filter for what state a personal session is in.
@@ -318,4 +325,50 @@ impl<'a> PersonalSessionFilter<'a> {
pub fn device(&self) -> Option<&'a Device> { pub fn device(&self) -> Option<&'a Device> {
self.device self.device
} }
/// Only return sessions whose access tokens expire before the given time
#[must_use]
pub fn with_expires_before(mut self, expires_before: DateTime<Utc>) -> Self {
self.expires_before = Some(expires_before);
self
}
/// Get the expires before filter
///
/// Returns [`None`] if no expires before filter was set
#[must_use]
pub fn expires_before(&self) -> Option<DateTime<Utc>> {
self.expires_before
}
/// Only return sessions whose access tokens expire after the given time
#[must_use]
pub fn with_expires_after(mut self, expires_after: DateTime<Utc>) -> Self {
self.expires_after = Some(expires_after);
self
}
/// Get the expires after filter
///
/// Returns [`None`] if no expires after filter was set
#[must_use]
pub fn expires_after(&self) -> Option<DateTime<Utc>> {
self.expires_after
}
/// Only return sessions whose access tokens have, or don't have,
/// an expiry time set
#[must_use]
pub fn with_expires(mut self, expires: bool) -> Self {
self.expires = Some(expires);
self
}
/// Get the expires filter
///
/// Returns [`None`] if no expires filter was set
#[must_use]
pub fn expires(&self) -> Option<bool> {
self.expires
}
} }

View File

@@ -259,7 +259,8 @@ impl RunnableJob for SyncDevicesJob {
.map_err(JobError::retry)?; .map_err(JobError::retry)?;
for edge in page.edges { for edge in page.edges {
for scope in &*edge.node.scope { let (session, _) = &edge.node;
for scope in &*session.scope {
if let Some(device) = Device::from_scope_token(scope) { if let Some(device) = Device::from_scope_token(scope) {
devices.insert(device.as_str().to_owned()); devices.insert(device.as_str().to_owned());
} }

View File

@@ -896,6 +896,572 @@
} }
} }
}, },
"/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[scope]",
"description": "Retrieve the items with the given scope",
"schema": {
"description": "Retrieve the items with the given scope",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"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"
},
{
"in": "query",
"name": "filter[expires_before]",
"description": "Filter by access token expiry date",
"schema": {
"description": "Filter by access token expiry date",
"type": "string",
"format": "date-time",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "filter[expires_after]",
"description": "Filter by access token expiry date",
"schema": {
"description": "Filter by access token expiry date",
"type": "string",
"format": "date-time",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "filter[expires]",
"description": "Filter by whether the access token has an expiry time",
"schema": {
"description": "Filter by whether the access token has an expiry time",
"type": "boolean",
"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",
"expires_at": null
},
"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",
"expires_at": null
},
"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",
"expires_at": "2022-01-24T04:36:40Z"
},
"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/SingleResponse_for_PersonalSession"
}
}
}
},
"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",
"expires_at": null
},
"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",
"expires_at": null
},
"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/personal-sessions/{id}/regenerate": {
"post": {
"tags": [
"personal-session"
],
"summary": "Regenerate a personal session by replacing its personal access token",
"operationId": "regeneratePersonalSession",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"title": "The ID of the resource",
"$ref": "#/components/schemas/ULID"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegeneratePersonalSessionRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Personal session was regenerated and a personal access token was created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
}
}
}
},
"404": {
"description": "User was not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"errors": [
{
"title": "User not found"
}
]
}
}
}
}
}
}
},
"/api/admin/v1/policy-data": { "/api/admin/v1/policy-data": {
"post": { "post": {
"tags": [ "tags": [
@@ -4579,6 +5145,249 @@
} }
} }
}, },
"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[scope]": {
"description": "Retrieve the items with the given scope",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"filter[status]": {
"description": "Filter by session status",
"$ref": "#/components/schemas/PersonalSessionStatus",
"nullable": true
},
"filter[expires_before]": {
"description": "Filter by access token expiry date",
"type": "string",
"format": "date-time",
"nullable": true
},
"filter[expires_after]": {
"description": "Filter by access token expiry date",
"type": "string",
"format": "date-time",
"nullable": true
},
"filter[expires]": {
"description": "Filter by whether the access token has an expiry time",
"type": "boolean",
"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",
"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",
"nullable": true
},
"owner_client_id": {
"description": "The ID of the `OAuth2` client that owns this session (if client-owned)",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"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
},
"expires_at": {
"description": "When the current token for this session expires. The session will need to be regenerated, producing a new access token, after this time. None if the current token won't expire or if the session is revoked.",
"type": "string",
"format": "date-time",
"nullable": true
},
"access_token": {
"description": "The actual access token (only returned on creation)",
"type": "string",
"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": "uint32",
"minimum": 0.0,
"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"
}
}
},
"RegeneratePersonalSessionRequest": {
"title": "JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint",
"type": "object",
"properties": {
"expires_in": {
"description": "Token expiry time in seconds. If not set, the token won't expire.",
"type": "integer",
"format": "uint32",
"minimum": 0.0,
"nullable": true
}
}
},
"SetPolicyDataRequest": { "SetPolicyDataRequest": {
"title": "JSON payload for the `POST /api/admin/v1/policy-data`", "title": "JSON payload for the `POST /api/admin/v1/policy-data`",
"type": "object", "type": "object",

0
misc/update.sh Normal file → Executable file
View File