Add personal sessions admin API
This commit is contained in:
@@ -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),
|
||||
|
||||
281
crates/handlers/src/admin/v1/personal_sessions/add.rs
Normal file
281
crates/handlers/src/admin/v1/personal_sessions/add.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
189
crates/handlers/src/admin/v1/personal_sessions/get.rs
Normal file
189
crates/handlers/src/admin/v1/personal_sessions/get.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
540
crates/handlers/src/admin/v1/personal_sessions/list.rs
Normal file
540
crates/handlers/src/admin/v1/personal_sessions/list.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
crates/handlers/src/admin/v1/personal_sessions/mod.rs
Normal file
16
crates/handlers/src/admin/v1/personal_sessions/mod.rs
Normal 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},
|
||||
};
|
||||
235
crates/handlers/src/admin/v1/personal_sessions/revoke.rs
Normal file
235
crates/handlers/src/admin/v1/personal_sessions/revoke.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user