Add personal sessions admin API

This commit is contained in:
Olivier 'reivilibre
2025-10-20 13:48:49 +01:00
parent 626154ccc9
commit e06fb33e37
7 changed files with 2058 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ use crate::passwords::PasswordManager;
mod compat_sessions;
mod oauth2_sessions;
mod personal_sessions;
mod policy_data;
mod site_config;
mod upstream_oauth_links;
@@ -80,6 +81,31 @@ where
self::oauth2_sessions::finish_doc,
),
)
.api_route(
"/personal-sessions",
get_with(
self::personal_sessions::list,
self::personal_sessions::list_doc,
)
.post_with(
self::personal_sessions::add,
self::personal_sessions::add_doc,
),
)
.api_route(
"/personal-sessions/{id}",
get_with(
self::personal_sessions::get,
self::personal_sessions::get_doc,
),
)
.api_route(
"/personal-sessions/{id}/revoke",
post_with(
self::personal_sessions::revoke,
self::personal_sessions::revoke_doc,
),
)
.api_route(
"/policy-data",
post_with(self::policy_data::set, self::policy_data::set_doc),

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<u64>,
}
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::try_from(exp_in).unwrap_or(i64::MAX))),
)
.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.id)
.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,540 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation};
use axum::{
Json,
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum_macros::FromRequestParts;
use chrono::{DateTime, Utc};
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_storage::personal::PersonalSessionFilter;
use schemars::JsonSchema;
use serde::Deserialize;
use ulid::Ulid;
use crate::{
admin::{
call_context::CallContext,
model::{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>,
/// 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>>,
}
impl std::fmt::Display for FilterParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut sep = '?';
if let Some(owner_user) = self.owner_user {
write!(f, "{sep}filter[owner_user]={owner_user}")?;
sep = '&';
}
if let Some(owner_client) = self.owner_client {
write!(f, "{sep}filter[owner_client]={owner_client}")?;
sep = '&';
}
if let Some(actor_user) = self.actor_user {
write!(f, "{sep}filter[actor_user]={actor_user}")?;
sep = '&';
}
if let Some(status) = self.status {
write!(f, "{sep}filter[status]={status}")?;
sep = '&';
}
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 = '&';
}
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),
}
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::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,
};
// Apply status filter
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 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]),
)
.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",
"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"]),
];
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,16 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
mod add;
mod get;
mod list;
mod revoke;
pub use self::{
add::{doc as add_doc, handler as add},
get::{doc as get_doc, handler as get},
list::{doc as list_doc, handler as list},
revoke::{doc as revoke_doc, handler as revoke},
};

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

@@ -896,6 +896,547 @@
}
}
},
"/api/admin/v1/personal-sessions": {
"get": {
"tags": [
"personal-session"
],
"summary": "List personal sessions",
"description": "Retrieve a list of personal sessions.\nNote that by default, all sessions, including revoked ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.",
"operationId": "listPersonalSessions",
"parameters": [
{
"in": "query",
"name": "page[before]",
"description": "Retrieve the items before the given ID",
"schema": {
"description": "Retrieve the items before the given ID",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "page[after]",
"description": "Retrieve the items after the given ID",
"schema": {
"description": "Retrieve the items after the given ID",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "page[first]",
"description": "Retrieve the first N items",
"schema": {
"description": "Retrieve the first N items",
"type": "integer",
"format": "uint",
"minimum": 1.0,
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "page[last]",
"description": "Retrieve the last N items",
"schema": {
"description": "Retrieve the last N items",
"type": "integer",
"format": "uint",
"minimum": 1.0,
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "count",
"description": "Include the total number of items. Defaults to `true`.",
"schema": {
"description": "Include the total number of items. Defaults to `true`.",
"$ref": "#/components/schemas/IncludeCount",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "filter[owner_user]",
"description": "Filter by owner user ID",
"schema": {
"description": "Filter by owner user ID",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "filter[owner_client]",
"description": "Filter by owner `OAuth2` client ID",
"schema": {
"description": "Filter by owner `OAuth2` client ID",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "filter[actor_user]",
"description": "Filter by actor user ID",
"schema": {
"description": "Filter by actor user ID",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "filter[status]",
"description": "Filter by session status",
"schema": {
"description": "Filter by session status",
"$ref": "#/components/schemas/PersonalSessionStatus",
"nullable": true
},
"style": "form"
},
{
"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"
}
],
"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": {
"post": {
"tags": [
@@ -4579,6 +5120,236 @@
}
}
},
"PersonalSessionFilter": {
"type": "object",
"properties": {
"filter[owner_user]": {
"description": "Filter by owner user ID",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"filter[owner_client]": {
"description": "Filter by owner `OAuth2` client ID",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"filter[actor_user]": {
"description": "Filter by actor user ID",
"$ref": "#/components/schemas/ULID",
"nullable": true
},
"filter[status]": {
"description": "Filter by session status",
"$ref": "#/components/schemas/PersonalSessionStatus",
"nullable": true
},
"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
}
}
},
"PersonalSessionStatus": {
"type": "string",
"enum": [
"active",
"revoked"
]
},
"PaginatedResponse_for_PersonalSession": {
"description": "A top-level response with a page of resources",
"type": "object",
"required": [
"links"
],
"properties": {
"meta": {
"description": "Response metadata",
"$ref": "#/components/schemas/PaginationMeta",
"nullable": true
},
"data": {
"description": "The list of resources",
"type": "array",
"items": {
"$ref": "#/components/schemas/SingleResource_for_PersonalSession"
},
"nullable": true
},
"links": {
"description": "Related links",
"$ref": "#/components/schemas/PaginationLinks"
}
}
},
"SingleResource_for_PersonalSession": {
"description": "A single resource, with its type, ID, attributes and related links",
"type": "object",
"required": [
"attributes",
"id",
"links",
"type"
],
"properties": {
"type": {
"description": "The type of the resource",
"type": "string"
},
"id": {
"description": "The ID of the resource",
"$ref": "#/components/schemas/ULID"
},
"attributes": {
"description": "The attributes of the resource",
"$ref": "#/components/schemas/PersonalSession"
},
"links": {
"description": "Related links",
"$ref": "#/components/schemas/SelfLinks"
},
"meta": {
"description": "Metadata about the resource",
"$ref": "#/components/schemas/SingleResourceMeta",
"nullable": true
}
}
},
"PersonalSession": {
"description": "A personal session (session using personal access tokens)",
"type": "object",
"required": [
"actor_user_id",
"created_at",
"human_name",
"owner_client_id",
"owner_user_id",
"scope"
],
"properties": {
"created_at": {
"description": "When the session was created",
"type": "string",
"format": "date-time"
},
"revoked_at": {
"description": "When the session was revoked, if applicable",
"type": "string",
"format": "date-time",
"nullable": true
},
"owner_user_id": {
"description": "The ID of the user who owns this session (if user-owned)",
"$ref": "#/components/schemas/ULID"
},
"owner_client_id": {
"description": "The ID of the `OAuth2` client that owns this session (if client-owned)",
"$ref": "#/components/schemas/ULID"
},
"actor_user_id": {
"description": "The ID of the user that the session acts on behalf of",
"$ref": "#/components/schemas/ULID"
},
"human_name": {
"description": "Human-readable name for the session",
"type": "string"
},
"scope": {
"description": "`OAuth2` scopes for this session",
"type": "string"
},
"last_active_at": {
"description": "When the session was last active",
"type": "string",
"format": "date-time",
"nullable": true
},
"last_active_ip": {
"description": "IP address of last activity",
"type": "string",
"format": "ip",
"nullable": true
},
"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": "uint64",
"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 will default to the same lifetime as when originally issued.",
"type": "integer",
"format": "uint64",
"minimum": 0.0,
"nullable": true
}
}
},
"SetPolicyDataRequest": {
"title": "JSON payload for the `POST /api/admin/v1/policy-data`",
"type": "object",