Personal Sessions: add create, list, get, revoke, regenerate Admin APIs (#5141)
Introduces some admin API endpoints for Personal Sessions. - add: Creates a personal session along with its first personal access token, returning both. This is currently the only way to get a personal access token. - get: Shows the information about a personal session - list: Shows many personal sessions - revoke: Revokes a personal session, so it can't be used anymore - regenerate: Revoke the active personal access token for a session and issue a new one to replace it.
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -613,6 +613,7 @@ dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http",
|
||||
@@ -622,6 +623,8 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde_core",
|
||||
"serde_html_form",
|
||||
"serde_path_to_error",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
||||
@@ -67,7 +67,7 @@ syn2mas = { path = "./crates/syn2mas", version = "=1.4.1" }
|
||||
# OpenAPI schema generation and validation
|
||||
[workspace.dependencies.aide]
|
||||
version = "0.14.2"
|
||||
features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"]
|
||||
features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"]
|
||||
|
||||
# An `Arc` that can be atomically updated
|
||||
[workspace.dependencies.arc-swap]
|
||||
@@ -101,7 +101,7 @@ version = "0.8.6"
|
||||
# Extra utilities for Axum
|
||||
[workspace.dependencies.axum-extra]
|
||||
version = "0.10.3"
|
||||
features = ["cookie-private", "cookie-key-expansion", "typed-header"]
|
||||
features = ["cookie-private", "cookie-key-expansion", "typed-header", "query"]
|
||||
|
||||
# Axum macros
|
||||
[workspace.dependencies.axum-macros]
|
||||
|
||||
@@ -17,4 +17,6 @@ disallowed-methods = [
|
||||
disallowed-types = [
|
||||
{ path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" },
|
||||
{ path = "std::path::Path", reason = "use camino::Utf8Path instead" },
|
||||
{ path = "axum::extract::Query", reason = "use axum_extra::extract::Query instead. The built-in version doesn't deserialise lists."},
|
||||
{ path = "axum::extract::rejection::QueryRejection", reason = "use axum_extra::extract::QueryRejection instead"}
|
||||
]
|
||||
|
||||
@@ -36,7 +36,9 @@ pub struct Transport {
|
||||
inner: Arc<TransportInner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum TransportInner {
|
||||
#[default]
|
||||
Blackhole,
|
||||
Smtp(AsyncSmtpTransport<Tokio1Executor>),
|
||||
Sendmail(AsyncSendmailTransport<Tokio1Executor>),
|
||||
@@ -113,12 +115,6 @@ impl Transport {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TransportInner {
|
||||
fn default() -> Self {
|
||||
Self::Blackhole
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error(transparent)]
|
||||
pub enum Error {
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::Device;
|
||||
use mas_data_model::{
|
||||
Device,
|
||||
personal::{
|
||||
PersonalAccessToken as DataModelPersonalAccessToken,
|
||||
session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner},
|
||||
},
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
use url::Url;
|
||||
|
||||
@@ -771,3 +778,179 @@ impl UpstreamOAuthProvider {
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that shouldn't happen in practice, but suggests database
|
||||
/// inconsistency.
|
||||
#[derive(Debug, Error)]
|
||||
#[error(
|
||||
"personal session {session_id} in inconsistent state: not revoked but no valid access token"
|
||||
)]
|
||||
pub struct InconsistentPersonalSession {
|
||||
pub session_id: Ulid,
|
||||
}
|
||||
|
||||
// Note: we don't expose a separate concept of personal access tokens to the
|
||||
// admin API; we merge the relevant attributes into the personal session.
|
||||
/// A personal session (session using personal access tokens)
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct PersonalSession {
|
||||
#[serde(skip)]
|
||||
id: Ulid,
|
||||
|
||||
/// When the session was created
|
||||
created_at: DateTime<Utc>,
|
||||
|
||||
/// When the session was revoked, if applicable
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
|
||||
/// The ID of the user who owns this session (if user-owned)
|
||||
#[schemars(with = "Option<super::schema::Ulid>")]
|
||||
owner_user_id: Option<Ulid>,
|
||||
|
||||
/// The ID of the `OAuth2` client that owns this session (if client-owned)
|
||||
#[schemars(with = "Option<super::schema::Ulid>")]
|
||||
owner_client_id: Option<Ulid>,
|
||||
|
||||
/// The ID of the user that the session acts on behalf of
|
||||
#[schemars(with = "super::schema::Ulid")]
|
||||
actor_user_id: Ulid,
|
||||
|
||||
/// Human-readable name for the session
|
||||
human_name: String,
|
||||
|
||||
/// `OAuth2` scopes for this session
|
||||
scope: String,
|
||||
|
||||
/// When the session was last active
|
||||
last_active_at: Option<DateTime<Utc>>,
|
||||
|
||||
/// IP address of last activity
|
||||
last_active_ip: Option<IpAddr>,
|
||||
|
||||
/// When the current token for this session expires.
|
||||
/// The session will need to be regenerated, producing a new access token,
|
||||
/// after this time.
|
||||
/// None if the current token won't expire or if the session is revoked.
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
|
||||
/// The actual access token (only returned on creation)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
access_token: Option<String>,
|
||||
}
|
||||
|
||||
impl
|
||||
TryFrom<(
|
||||
DataModelPersonalSession,
|
||||
Option<DataModelPersonalAccessToken>,
|
||||
)> for PersonalSession
|
||||
{
|
||||
type Error = InconsistentPersonalSession;
|
||||
|
||||
fn try_from(
|
||||
(session, token): (
|
||||
DataModelPersonalSession,
|
||||
Option<DataModelPersonalAccessToken>,
|
||||
),
|
||||
) -> Result<Self, InconsistentPersonalSession> {
|
||||
let expires_at = if let Some(token) = token {
|
||||
token.expires_at
|
||||
} else {
|
||||
if !session.is_revoked() {
|
||||
// No active token, but the session is not revoked.
|
||||
return Err(InconsistentPersonalSession {
|
||||
session_id: session.id,
|
||||
});
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let (owner_user_id, owner_client_id) = match session.owner {
|
||||
PersonalSessionOwner::User(id) => (Some(id), None),
|
||||
PersonalSessionOwner::OAuth2Client(id) => (None, Some(id)),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
id: session.id,
|
||||
created_at: session.created_at,
|
||||
revoked_at: session.revoked_at(),
|
||||
owner_user_id,
|
||||
owner_client_id,
|
||||
actor_user_id: session.actor_user_id,
|
||||
human_name: session.human_name,
|
||||
scope: session.scope.to_string(),
|
||||
last_active_at: session.last_active_at,
|
||||
last_active_ip: session.last_active_ip,
|
||||
expires_at,
|
||||
// If relevant, the caller will populate using `with_token` afterwards.
|
||||
access_token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Resource for PersonalSession {
|
||||
const KIND: &'static str = "personal-session";
|
||||
const PATH: &'static str = "/api/admin/v1/personal-sessions";
|
||||
|
||||
fn id(&self) -> Ulid {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl PersonalSession {
|
||||
/// Sample personal sessions for documentation/testing
|
||||
pub fn samples() -> [Self; 3] {
|
||||
[
|
||||
Self {
|
||||
id: Ulid::from_string("01FSHN9AG0AJ6AC5HQ9X6H4RP4").unwrap(),
|
||||
created_at: DateTime::from_timestamp(1_642_338_000, 0).unwrap(), /* 2022-01-16T14:
|
||||
* 40:00Z */
|
||||
revoked_at: None,
|
||||
owner_user_id: Some(Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap()),
|
||||
owner_client_id: None,
|
||||
actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(),
|
||||
human_name: "Alice's Development Token".to_owned(),
|
||||
scope: "openid urn:matrix:org.matrix.msc2967.client:api:*".to_owned(),
|
||||
last_active_at: Some(DateTime::from_timestamp(1_642_347_000, 0).unwrap()), /* 2022-01-16T17:10:00Z */
|
||||
last_active_ip: Some("192.168.1.100".parse().unwrap()),
|
||||
expires_at: None,
|
||||
access_token: None,
|
||||
},
|
||||
Self {
|
||||
id: Ulid::from_string("01FSHN9AG0BJ6AC5HQ9X6H4RP5").unwrap(),
|
||||
created_at: DateTime::from_timestamp(1_642_338_060, 0).unwrap(), /* 2022-01-16T14:
|
||||
* 41:00Z */
|
||||
revoked_at: Some(DateTime::from_timestamp(1_642_350_000, 0).unwrap()), /* 2022-01-16T18:00:00Z */
|
||||
owner_user_id: Some(Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap()),
|
||||
owner_client_id: None,
|
||||
actor_user_id: Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap(),
|
||||
human_name: "Bob's Mobile App".to_owned(),
|
||||
scope: "openid".to_owned(),
|
||||
last_active_at: Some(DateTime::from_timestamp(1_642_349_000, 0).unwrap()), /* 2022-01-16T17:43:20Z */
|
||||
last_active_ip: Some("10.0.0.50".parse().unwrap()),
|
||||
expires_at: None,
|
||||
access_token: None,
|
||||
},
|
||||
Self {
|
||||
id: Ulid::from_string("01FSHN9AG0CJ6AC5HQ9X6H4RP6").unwrap(),
|
||||
created_at: DateTime::from_timestamp(1_642_338_120, 0).unwrap(), /* 2022-01-16T14:
|
||||
* 42:00Z */
|
||||
revoked_at: None,
|
||||
owner_user_id: None,
|
||||
owner_client_id: Some(Ulid::from_string("01FSHN9AG0DJ6AC5HQ9X6H4RP7").unwrap()),
|
||||
actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(),
|
||||
human_name: "CI/CD Pipeline Token".to_owned(),
|
||||
scope: "openid urn:mas:admin".to_owned(),
|
||||
last_active_at: Some(DateTime::from_timestamp(1_642_348_000, 0).unwrap()), /* 2022-01-16T17:26:40Z */
|
||||
last_active_ip: Some("203.0.113.10".parse().unwrap()),
|
||||
expires_at: Some(DateTime::from_timestamp(1_642_999_000, 0).unwrap()),
|
||||
access_token: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Add the actual token value (for use in creation responses)
|
||||
pub fn with_token(mut self, access_token: String) -> Self {
|
||||
self.access_token = Some(access_token);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,10 @@ use std::{borrow::Cow, num::NonZeroUsize};
|
||||
use aide::OperationIo;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{
|
||||
FromRequestParts, Path, Query,
|
||||
rejection::{PathRejection, QueryRejection},
|
||||
},
|
||||
extract::{FromRequestParts, Path, rejection::PathRejection},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_storage::pagination::PaginationDirection;
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
// 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::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
|
||||
@@ -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,38 @@ 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(
|
||||
"/personal-sessions/{id}/regenerate",
|
||||
post_with(
|
||||
self::personal_sessions::regenerate,
|
||||
self::personal_sessions::regenerate_doc,
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/policy-data",
|
||||
post_with(self::policy_data::set, self::policy_data::set_doc),
|
||||
|
||||
@@ -7,11 +7,8 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use aide::{OperationIo, transform::TransformOperation};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, rejection::QueryRejection},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
|
||||
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<u32>,
|
||||
}
|
||||
|
||||
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||
operation
|
||||
.id("createPersonalSession")
|
||||
.summary("Create a new personal session with personal access token")
|
||||
.tag("personal-session")
|
||||
.response_with::<201, Json<SingleResponse<PersonalSession>>, _>(|t| {
|
||||
t.description("Personal session and personal access token were created")
|
||||
})
|
||||
.response_with::<400, RouteError, _>(|t| {
|
||||
let response = ErrorResponse::from_error(&RouteError::InvalidScope);
|
||||
t.description("Invalid scope provided").example(response)
|
||||
})
|
||||
.response_with::<404, RouteError, _>(|t| {
|
||||
let response = ErrorResponse::from_error(&RouteError::UserNotFound);
|
||||
t.description("User was not found").example(response)
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)]
|
||||
pub async fn handler(
|
||||
CallContext {
|
||||
mut repo,
|
||||
clock,
|
||||
session,
|
||||
..
|
||||
}: CallContext,
|
||||
NoApi(mut rng): NoApi<BoxRng>,
|
||||
Json(params): Json<Request>,
|
||||
) -> Result<(StatusCode, Json<SingleResponse<PersonalSession>>), RouteError> {
|
||||
let owner = if let Some(user_id) = session.user_id {
|
||||
// User-owned session
|
||||
PersonalSessionOwner::User(user_id)
|
||||
} else {
|
||||
// No admin user means this is a client-owned session
|
||||
PersonalSessionOwner::OAuth2Client(session.client_id)
|
||||
};
|
||||
|
||||
let actor_user = repo
|
||||
.user()
|
||||
.lookup(params.actor_user_id)
|
||||
.await?
|
||||
.ok_or(RouteError::UserNotFound)?;
|
||||
|
||||
let scope: Scope = params.scope.parse().map_err(|_| RouteError::InvalidScope)?;
|
||||
|
||||
// Create the personal session
|
||||
let session = repo
|
||||
.personal_session()
|
||||
.add(
|
||||
&mut rng,
|
||||
&clock,
|
||||
owner,
|
||||
&actor_user,
|
||||
params.human_name,
|
||||
scope,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create the initial token for the session
|
||||
let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng);
|
||||
let access_token = repo
|
||||
.personal_access_token()
|
||||
.add(
|
||||
&mut rng,
|
||||
&clock,
|
||||
&session,
|
||||
&access_token_string,
|
||||
params
|
||||
.expires_in
|
||||
.map(|exp_in| Duration::seconds(i64::from(exp_in))),
|
||||
)
|
||||
.await?;
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(SingleResponse::new_canonical(
|
||||
PersonalSession::try_from((session, Some(access_token)))?
|
||||
.with_token(access_token_string),
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hyper::{Request, StatusCode};
|
||||
use insta::assert_json_snapshot;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_create_personal_session_with_token(pool: PgPool) {
|
||||
setup();
|
||||
let mut state = TestState::from_pool(pool).await.unwrap();
|
||||
let token = state.token_with_scope("urn:mas:admin").await;
|
||||
|
||||
// Create a user for testing
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let mut rng = state.rng();
|
||||
let user = repo
|
||||
.user()
|
||||
.add(&mut rng, &state.clock, "alice".to_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"actor_user_id": user.id,
|
||||
"human_name": "Test Session",
|
||||
"scope": "openid urn:mas:admin",
|
||||
"expires_in": 3600
|
||||
});
|
||||
|
||||
let request = Request::post("/api/admin/v1/personal-sessions")
|
||||
.bearer(&token)
|
||||
.json(&request_body);
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CREATED);
|
||||
|
||||
let body: Value = response.json();
|
||||
|
||||
assert_json_snapshot!(body, @r#"
|
||||
{
|
||||
"data": {
|
||||
"type": "personal-session",
|
||||
"id": "01FSHN9AG07HNEZXNQM2KNBNF6",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-16T14:40:00Z",
|
||||
"revoked_at": null,
|
||||
"owner_user_id": null,
|
||||
"owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR",
|
||||
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"human_name": "Test Session",
|
||||
"scope": "openid urn:mas:admin",
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"expires_at": "2022-01-16T15:40:00Z",
|
||||
"access_token": "mpt_FM44zJN5qePGMLvvMXC4Ds1A3lCWc6_bJ9Wj1"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
|
||||
}
|
||||
}
|
||||
"#);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_create_personal_session_invalid_user(pool: PgPool) {
|
||||
setup();
|
||||
let mut state = TestState::from_pool(pool).await.unwrap();
|
||||
let token = state.token_with_scope("urn:mas:admin").await;
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"scope": "openid",
|
||||
"human_name": "Test Session",
|
||||
"expires_in": 3600
|
||||
});
|
||||
|
||||
let request = Request::post("/api/admin/v1/personal-sessions")
|
||||
.bearer(&token)
|
||||
.json(&request_body);
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_create_personal_session_invalid_scope(pool: PgPool) {
|
||||
setup();
|
||||
let mut state = TestState::from_pool(pool).await.unwrap();
|
||||
let token = state.token_with_scope("urn:mas:admin").await;
|
||||
|
||||
// Create a user for testing
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let mut rng = state.rng();
|
||||
let user = repo
|
||||
.user()
|
||||
.add(&mut rng, &state.clock, "alice".to_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"actor_user_id": user.id,
|
||||
"human_name": "Test Session",
|
||||
"scope": "invalid\nscope",
|
||||
"expires_in": 3600
|
||||
});
|
||||
|
||||
let request = Request::post("/api/admin/v1/personal-sessions")
|
||||
.bearer(&token)
|
||||
.json(&request_body);
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
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)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
585
crates/handlers/src/admin/v1/personal_sessions/list.rs
Normal file
585
crates/handlers/src/admin/v1/personal_sessions/list.rs
Normal file
@@ -0,0 +1,585 @@
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use std::str::FromStr as _;
|
||||
|
||||
use aide::{OperationIo, transform::TransformOperation};
|
||||
use axum::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use chrono::{DateTime, Utc};
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
use mas_storage::personal::PersonalSessionFilter;
|
||||
use oauth2_types::scope::{Scope, ScopeToken};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
call_context::CallContext,
|
||||
model::{InconsistentPersonalSession, PersonalSession, Resource},
|
||||
params::{IncludeCount, Pagination},
|
||||
response::{ErrorResponse, PaginatedResponse},
|
||||
},
|
||||
impl_from_error_for_route,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Clone, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum PersonalSessionStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PersonalSessionStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Active => write!(f, "active"),
|
||||
Self::Revoked => write!(f, "revoked"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
|
||||
#[serde(rename = "PersonalSessionFilter")]
|
||||
#[aide(input_with = "Query<FilterParams>")]
|
||||
#[from_request(via(Query), rejection(RouteError))]
|
||||
pub struct FilterParams {
|
||||
/// Filter by owner user ID
|
||||
#[serde(rename = "filter[owner_user]")]
|
||||
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
|
||||
owner_user: Option<Ulid>,
|
||||
|
||||
/// Filter by owner `OAuth2` client ID
|
||||
#[serde(rename = "filter[owner_client]")]
|
||||
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
|
||||
owner_client: Option<Ulid>,
|
||||
|
||||
/// Filter by actor user ID
|
||||
#[serde(rename = "filter[actor_user]")]
|
||||
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
|
||||
actor_user: Option<Ulid>,
|
||||
|
||||
/// Retrieve the items with the given scope
|
||||
#[serde(default, rename = "filter[scope]")]
|
||||
scope: Vec<String>,
|
||||
|
||||
/// Filter by session status
|
||||
#[serde(rename = "filter[status]")]
|
||||
status: Option<PersonalSessionStatus>,
|
||||
|
||||
/// Filter by access token expiry date
|
||||
#[serde(rename = "filter[expires_before]")]
|
||||
expires_before: Option<DateTime<Utc>>,
|
||||
|
||||
/// Filter by access token expiry date
|
||||
#[serde(rename = "filter[expires_after]")]
|
||||
expires_after: Option<DateTime<Utc>>,
|
||||
|
||||
/// Filter by whether the access token has an expiry time
|
||||
#[serde(rename = "filter[expires]")]
|
||||
expires: Option<bool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FilterParams {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut sep = '?';
|
||||
|
||||
if let Some(owner_user) = self.owner_user {
|
||||
write!(f, "{sep}filter[owner_user]={owner_user}")?;
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(owner_client) = self.owner_client {
|
||||
write!(f, "{sep}filter[owner_client]={owner_client}")?;
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(actor_user) = self.actor_user {
|
||||
write!(f, "{sep}filter[actor_user]={actor_user}")?;
|
||||
sep = '&';
|
||||
}
|
||||
for scope in &self.scope {
|
||||
write!(f, "{sep}filter[scope]={scope}")?;
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(status) = self.status {
|
||||
write!(f, "{sep}filter[status]={status}")?;
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(expires_before) = self.expires_before {
|
||||
write!(
|
||||
f,
|
||||
"{sep}filter[expires_before]={}",
|
||||
expires_before.format("%Y-%m-%dT%H:%M:%SZ")
|
||||
)?;
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(expires_after) = self.expires_after {
|
||||
write!(
|
||||
f,
|
||||
"{sep}filter[expires_after]={}",
|
||||
expires_after.format("%Y-%m-%dT%H:%M:%SZ")
|
||||
)?;
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(expires) = self.expires {
|
||||
write!(f, "{sep}filter[expires]={expires}")?;
|
||||
sep = '&';
|
||||
}
|
||||
|
||||
let _ = sep;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, OperationIo)]
|
||||
#[aide(output_with = "Json<ErrorResponse>")]
|
||||
pub enum RouteError {
|
||||
#[error(transparent)]
|
||||
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
|
||||
#[error("User ID {0} not found")]
|
||||
UserNotFound(Ulid),
|
||||
|
||||
#[error("Client ID {0} not found")]
|
||||
ClientNotFound(Ulid),
|
||||
|
||||
#[error("Invalid filter parameters")]
|
||||
InvalidFilter(#[from] QueryRejection),
|
||||
|
||||
#[error("Invalid scope {0:?} in filter parameters")]
|
||||
InvalidScope(String),
|
||||
}
|
||||
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
impl_from_error_for_route!(InconsistentPersonalSession);
|
||||
|
||||
impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let error = ErrorResponse::from_error(&self);
|
||||
let sentry_event_id = record_error!(self, Self::Internal(_));
|
||||
let status = match self {
|
||||
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::UserNotFound(_) | Self::ClientNotFound(_) => StatusCode::NOT_FOUND,
|
||||
Self::InvalidScope(_) | Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
|
||||
};
|
||||
(status, sentry_event_id, Json(error)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||
operation
|
||||
.id("listPersonalSessions")
|
||||
.summary("List personal sessions")
|
||||
.description("Retrieve a list of personal sessions.
|
||||
Note that by default, all sessions, including revoked ones are returned, with the oldest first.
|
||||
Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.")
|
||||
.tag("personal-session")
|
||||
.response_with::<200, Json<PaginatedResponse<PersonalSession>>, _>(|t| {
|
||||
let sessions = PersonalSession::samples();
|
||||
let pagination = mas_storage::Pagination::first(sessions.len());
|
||||
let page = mas_storage::Page {
|
||||
edges: sessions
|
||||
.into_iter()
|
||||
.map(|node| mas_storage::pagination::Edge {
|
||||
cursor: node.id(),
|
||||
node,
|
||||
})
|
||||
.collect(),
|
||||
has_next_page: true,
|
||||
has_previous_page: false,
|
||||
};
|
||||
|
||||
t.description("Paginated response of personal sessions")
|
||||
.example(PaginatedResponse::for_page(
|
||||
page,
|
||||
pagination,
|
||||
Some(3),
|
||||
PersonalSession::PATH,
|
||||
))
|
||||
})
|
||||
.response_with::<404, RouteError, _>(|t| {
|
||||
let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
|
||||
t.description("User was not found").example(response)
|
||||
})
|
||||
.response_with::<404, RouteError, _>(|t| {
|
||||
let response = ErrorResponse::from_error(&RouteError::ClientNotFound(Ulid::nil()));
|
||||
t.description("Client was not found").example(response)
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "handler.admin.v1.personal_sessions.list", skip_all)]
|
||||
pub async fn handler(
|
||||
CallContext { mut repo, .. }: CallContext,
|
||||
Pagination(pagination, include_count): Pagination,
|
||||
params: FilterParams,
|
||||
) -> Result<Json<PaginatedResponse<PersonalSession>>, RouteError> {
|
||||
let base = format!("{path}{params}", path = PersonalSession::PATH);
|
||||
let base = include_count.add_to_base(&base);
|
||||
|
||||
let filter = PersonalSessionFilter::new();
|
||||
|
||||
let owner_user = if let Some(owner_user_id) = params.owner_user {
|
||||
let owner_user = repo
|
||||
.user()
|
||||
.lookup(owner_user_id)
|
||||
.await?
|
||||
.ok_or(RouteError::UserNotFound(owner_user_id))?;
|
||||
Some(owner_user)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let filter = match &owner_user {
|
||||
Some(user) => filter.for_owner_user(user),
|
||||
None => filter,
|
||||
};
|
||||
|
||||
let owner_client = if let Some(owner_client_id) = params.owner_client {
|
||||
let owner_client = repo
|
||||
.oauth2_client()
|
||||
.lookup(owner_client_id)
|
||||
.await?
|
||||
.ok_or(RouteError::ClientNotFound(owner_client_id))?;
|
||||
Some(owner_client)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let filter = match &owner_client {
|
||||
Some(client) => filter.for_owner_oauth2_client(client),
|
||||
None => filter,
|
||||
};
|
||||
|
||||
let actor_user = if let Some(actor_user_id) = params.actor_user {
|
||||
let user = repo
|
||||
.user()
|
||||
.lookup(actor_user_id)
|
||||
.await?
|
||||
.ok_or(RouteError::UserNotFound(actor_user_id))?;
|
||||
Some(user)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let filter = match &actor_user {
|
||||
Some(user) => filter.for_actor_user(user),
|
||||
None => filter,
|
||||
};
|
||||
|
||||
let scope: Scope = params
|
||||
.scope
|
||||
.into_iter()
|
||||
.map(|s| ScopeToken::from_str(&s).map_err(|_| RouteError::InvalidScope(s)))
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let filter = if scope.is_empty() {
|
||||
filter
|
||||
} else {
|
||||
filter.with_scope(&scope)
|
||||
};
|
||||
|
||||
let filter = match params.status {
|
||||
Some(PersonalSessionStatus::Active) => filter.active_only(),
|
||||
Some(PersonalSessionStatus::Revoked) => filter.finished_only(),
|
||||
None => filter,
|
||||
};
|
||||
|
||||
let filter = if let Some(expires_after) = params.expires_after {
|
||||
filter.with_expires_after(expires_after)
|
||||
} else {
|
||||
filter
|
||||
};
|
||||
|
||||
let filter = if let Some(expires_before) = params.expires_before {
|
||||
filter.with_expires_before(expires_before)
|
||||
} else {
|
||||
filter
|
||||
};
|
||||
|
||||
let filter = if let Some(expires) = params.expires {
|
||||
filter.with_expires(expires)
|
||||
} else {
|
||||
filter
|
||||
};
|
||||
|
||||
let response = match include_count {
|
||||
IncludeCount::True => {
|
||||
let page = repo.personal_session().list(filter, pagination).await?;
|
||||
let count = repo.personal_session().count(filter).await?;
|
||||
PaginatedResponse::for_page(
|
||||
page.try_map(PersonalSession::try_from)?,
|
||||
pagination,
|
||||
Some(count),
|
||||
&base,
|
||||
)
|
||||
}
|
||||
IncludeCount::False => {
|
||||
let page = repo.personal_session().list(filter, pagination).await?;
|
||||
PaginatedResponse::for_page(
|
||||
page.try_map(PersonalSession::try_from)?,
|
||||
pagination,
|
||||
None,
|
||||
&base,
|
||||
)
|
||||
}
|
||||
IncludeCount::Only => {
|
||||
let count = repo.personal_session().count(filter).await?;
|
||||
PaginatedResponse::for_count_only(count, &base)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use chrono::Duration;
|
||||
use hyper::{Request, StatusCode};
|
||||
use insta::assert_json_snapshot;
|
||||
use mas_data_model::personal::session::PersonalSessionOwner;
|
||||
use oauth2_types::scope::{OPENID, Scope};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_list(pool: PgPool) {
|
||||
setup();
|
||||
let mut state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
// Create a user and personal session for testing
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let mut rng = state.rng();
|
||||
let user = repo
|
||||
.user()
|
||||
.add(&mut rng, &state.clock, "alice".to_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let personal_session = repo
|
||||
.personal_session()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
PersonalSessionOwner::from(&user),
|
||||
&user,
|
||||
"Test session".to_owned(),
|
||||
Scope::from_iter([OPENID]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.personal_access_token()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&personal_session,
|
||||
"mpt_hiss",
|
||||
Some(Duration::days(42)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
state.clock.advance(Duration::days(1));
|
||||
|
||||
let personal_session = repo
|
||||
.personal_session()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
PersonalSessionOwner::from(&user),
|
||||
&user,
|
||||
"Another test session".to_owned(),
|
||||
Scope::from_iter([OPENID]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.personal_access_token()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&personal_session,
|
||||
"mpt_scratch",
|
||||
Some(Duration::days(21)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.personal_session()
|
||||
.revoke(&state.clock, personal_session)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
state.clock.advance(Duration::days(1));
|
||||
|
||||
let personal_session = repo
|
||||
.personal_session()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
PersonalSessionOwner::from(&user),
|
||||
&user,
|
||||
"Another test session".to_owned(),
|
||||
Scope::from_iter([OPENID, "urn:mas:admin".parse().unwrap()]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.personal_access_token()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&personal_session,
|
||||
"mpt_meow",
|
||||
Some(Duration::days(14)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let token = state.token_with_scope("urn:mas:admin").await;
|
||||
let request = Request::get("/api/admin/v1/personal-sessions")
|
||||
.bearer(&token)
|
||||
.empty();
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
let body: serde_json::Value = response.json();
|
||||
assert_json_snapshot!(body, @r#"
|
||||
{
|
||||
"meta": {
|
||||
"count": 3
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"type": "personal-session",
|
||||
"id": "01FSHN9AG0YQYAR04VCYTHJ8SK",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-16T14:40:00Z",
|
||||
"revoked_at": null,
|
||||
"owner_user_id": "01FSHN9AG09FE39KETP6F390F8",
|
||||
"owner_client_id": null,
|
||||
"actor_user_id": "01FSHN9AG09FE39KETP6F390F8",
|
||||
"human_name": "Test session",
|
||||
"scope": "openid",
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"expires_at": "2022-02-27T14:40:00Z"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0YQYAR04VCYTHJ8SK"
|
||||
},
|
||||
"meta": {
|
||||
"page": {
|
||||
"cursor": "01FSHN9AG0YQYAR04VCYTHJ8SK"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "personal-session",
|
||||
"id": "01FSM7P1G0VBGAMK9D9QMGQ5MY",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-17T14:40:00Z",
|
||||
"revoked_at": "2022-01-17T14:40:00Z",
|
||||
"owner_user_id": "01FSHN9AG09FE39KETP6F390F8",
|
||||
"owner_client_id": null,
|
||||
"actor_user_id": "01FSHN9AG09FE39KETP6F390F8",
|
||||
"human_name": "Another test session",
|
||||
"scope": "openid",
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"expires_at": null
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSM7P1G0VBGAMK9D9QMGQ5MY"
|
||||
},
|
||||
"meta": {
|
||||
"page": {
|
||||
"cursor": "01FSM7P1G0VBGAMK9D9QMGQ5MY"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "personal-session",
|
||||
"id": "01FSPT2RG08Y11Y5BM4VZ4CN8K",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-18T14:40:00Z",
|
||||
"revoked_at": null,
|
||||
"owner_user_id": "01FSHN9AG09FE39KETP6F390F8",
|
||||
"owner_client_id": null,
|
||||
"actor_user_id": "01FSHN9AG09FE39KETP6F390F8",
|
||||
"human_name": "Another test session",
|
||||
"scope": "openid urn:mas:admin",
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"expires_at": "2022-02-01T14:40:00Z"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSPT2RG08Y11Y5BM4VZ4CN8K"
|
||||
},
|
||||
"meta": {
|
||||
"page": {
|
||||
"cursor": "01FSPT2RG08Y11Y5BM4VZ4CN8K"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions?page[first]=10",
|
||||
"first": "/api/admin/v1/personal-sessions?page[first]=10",
|
||||
"last": "/api/admin/v1/personal-sessions?page[last]=10"
|
||||
}
|
||||
}
|
||||
"#);
|
||||
|
||||
// Map of filters to their expected set of returned ULIDs
|
||||
let filters_and_expected: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"filter[expires_before]=2022-02-15T00:00:00Z",
|
||||
&["01FSPT2RG08Y11Y5BM4VZ4CN8K"],
|
||||
),
|
||||
(
|
||||
"filter[expires_after]=2022-02-15T00:00:00Z",
|
||||
&["01FSHN9AG0YQYAR04VCYTHJ8SK"],
|
||||
),
|
||||
(
|
||||
"filter[status]=active",
|
||||
&["01FSHN9AG0YQYAR04VCYTHJ8SK", "01FSPT2RG08Y11Y5BM4VZ4CN8K"],
|
||||
),
|
||||
("filter[status]=revoked", &["01FSM7P1G0VBGAMK9D9QMGQ5MY"]),
|
||||
(
|
||||
"filter[expires]=true",
|
||||
&["01FSHN9AG0YQYAR04VCYTHJ8SK", "01FSPT2RG08Y11Y5BM4VZ4CN8K"],
|
||||
),
|
||||
("filter[expires]=false", &["01FSM7P1G0VBGAMK9D9QMGQ5MY"]),
|
||||
(
|
||||
"filter[scope]=urn:mas:admin",
|
||||
&["01FSPT2RG08Y11Y5BM4VZ4CN8K"],
|
||||
),
|
||||
];
|
||||
|
||||
for (filter, expected_ids) in filters_and_expected {
|
||||
let request = Request::get(format!("/api/admin/v1/personal-sessions?{filter}"))
|
||||
.bearer(&token)
|
||||
.empty();
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
let body: serde_json::Value = response.json();
|
||||
let found: BTreeSet<&str> = body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["id"].as_str().unwrap())
|
||||
.collect();
|
||||
let expected: BTreeSet<&str> = expected_ids.iter().copied().collect();
|
||||
|
||||
assert_eq!(
|
||||
found, expected,
|
||||
"filter {filter} did not produce expected results"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
crates/handlers/src/admin/v1/personal_sessions/mod.rs
Normal file
18
crates/handlers/src/admin/v1/personal_sessions/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
mod add;
|
||||
mod get;
|
||||
mod list;
|
||||
mod regenerate;
|
||||
mod revoke;
|
||||
|
||||
pub use self::{
|
||||
add::{doc as add_doc, handler as add},
|
||||
get::{doc as get_doc, handler as get},
|
||||
list::{doc as list_doc, handler as list},
|
||||
regenerate::{doc as regenerate_doc, handler as regenerate},
|
||||
revoke::{doc as revoke_doc, handler as revoke},
|
||||
};
|
||||
249
crates/handlers/src/admin/v1/personal_sessions/regenerate.rs
Normal file
249
crates/handlers/src/admin/v1/personal_sessions/regenerate.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use aide::{NoApi, OperationIo, transform::TransformOperation};
|
||||
use axum::{Json, response::IntoResponse};
|
||||
use chrono::Duration;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
use mas_data_model::{BoxRng, TokenType, personal::session::PersonalSessionOwner};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
call_context::CallContext,
|
||||
model::{InconsistentPersonalSession, PersonalSession},
|
||||
params::UlidPathParam,
|
||||
response::{ErrorResponse, SingleResponse},
|
||||
},
|
||||
impl_from_error_for_route,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error, OperationIo)]
|
||||
#[aide(output_with = "Json<ErrorResponse>")]
|
||||
pub enum RouteError {
|
||||
#[error(transparent)]
|
||||
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
|
||||
#[error("User not found")]
|
||||
UserNotFound,
|
||||
|
||||
#[error("Session not found")]
|
||||
SessionNotFound,
|
||||
|
||||
#[error("Session not valid")]
|
||||
SessionNotValid,
|
||||
|
||||
#[error("Session does not belong to you")]
|
||||
SessionNotYours,
|
||||
}
|
||||
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
impl_from_error_for_route!(InconsistentPersonalSession);
|
||||
|
||||
impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let error = ErrorResponse::from_error(&self);
|
||||
let sentry_event_id = record_error!(self, Self::Internal(_));
|
||||
let status = match self {
|
||||
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::UserNotFound | Self::SessionNotFound => StatusCode::NOT_FOUND,
|
||||
Self::SessionNotValid => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Self::SessionNotYours => StatusCode::FORBIDDEN,
|
||||
};
|
||||
(status, sentry_event_id, Json(error)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// # JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(rename = "RegeneratePersonalSessionRequest")]
|
||||
pub struct Request {
|
||||
/// Token expiry time in seconds.
|
||||
/// If not set, the token won't expire.
|
||||
expires_in: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||
operation
|
||||
.id("regeneratePersonalSession")
|
||||
.summary("Regenerate a personal session by replacing its personal access token")
|
||||
.tag("personal-session")
|
||||
.response_with::<201, Json<SingleResponse<PersonalSession>>, _>(|t| {
|
||||
t.description(
|
||||
"Personal session was regenerated and a personal access token was created",
|
||||
)
|
||||
})
|
||||
.response_with::<404, RouteError, _>(|t| {
|
||||
let response = ErrorResponse::from_error(&RouteError::UserNotFound);
|
||||
t.description("User was not found").example(response)
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)]
|
||||
pub async fn handler(
|
||||
CallContext {
|
||||
mut repo,
|
||||
clock,
|
||||
session: caller_session,
|
||||
..
|
||||
}: CallContext,
|
||||
NoApi(mut rng): NoApi<BoxRng>,
|
||||
id: UlidPathParam,
|
||||
Json(params): Json<Request>,
|
||||
) -> Result<(StatusCode, Json<SingleResponse<PersonalSession>>), RouteError> {
|
||||
let session_id = *id;
|
||||
|
||||
let session = repo
|
||||
.personal_session()
|
||||
.lookup(session_id)
|
||||
.await?
|
||||
.ok_or(RouteError::SessionNotFound)?;
|
||||
|
||||
if !session.is_valid() {
|
||||
// We don't revive revoked sessions through regeneration
|
||||
return Err(RouteError::SessionNotValid);
|
||||
}
|
||||
|
||||
// If the owner is not the current caller, then currently we reject the
|
||||
// regeneration.
|
||||
let caller = if let Some(user_id) = caller_session.user_id {
|
||||
PersonalSessionOwner::User(user_id)
|
||||
} else {
|
||||
PersonalSessionOwner::OAuth2Client(caller_session.client_id)
|
||||
};
|
||||
if session.owner != caller {
|
||||
return Err(RouteError::SessionNotYours);
|
||||
}
|
||||
|
||||
// Revoke the existing active token for the session.
|
||||
let old_token_opt = repo
|
||||
.personal_access_token()
|
||||
.find_active_for_session(&session)
|
||||
.await?;
|
||||
let Some(old_token) = old_token_opt else {
|
||||
// This shouldn't happen
|
||||
error!("session is supposedly valid but had no access token");
|
||||
return Err(RouteError::SessionNotValid);
|
||||
};
|
||||
|
||||
repo.personal_access_token()
|
||||
.revoke(&clock, old_token)
|
||||
.await?;
|
||||
|
||||
// Create the regenerated token for the session
|
||||
let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng);
|
||||
let access_token = repo
|
||||
.personal_access_token()
|
||||
.add(
|
||||
&mut rng,
|
||||
&clock,
|
||||
&session,
|
||||
&access_token_string,
|
||||
params
|
||||
.expires_in
|
||||
.map(|exp_in| Duration::seconds(i64::from(exp_in))),
|
||||
)
|
||||
.await?;
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(SingleResponse::new_canonical(
|
||||
PersonalSession::try_from((session, Some(access_token)))?
|
||||
.with_token(access_token_string),
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Duration;
|
||||
use hyper::{Request, StatusCode};
|
||||
use insta::assert_json_snapshot;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_regenerate_personal_session(pool: PgPool) {
|
||||
setup();
|
||||
let mut state = TestState::from_pool(pool).await.unwrap();
|
||||
let token = state.token_with_scope("urn:mas:admin").await;
|
||||
|
||||
// Create a user for testing
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let mut rng = state.rng();
|
||||
let user = repo
|
||||
.user()
|
||||
.add(&mut rng, &state.clock, "alice".to_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let request = Request::post("/api/admin/v1/personal-sessions")
|
||||
.bearer(&token)
|
||||
.json(json!({
|
||||
"actor_user_id": user.id,
|
||||
"human_name": "SuperDuperAdminCLITool Token",
|
||||
"scope": "openid urn:mas:admin",
|
||||
"expires_in": 3600
|
||||
}));
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CREATED);
|
||||
let created: Value = response.json();
|
||||
|
||||
let session_id = created["data"]["id"].as_str().unwrap();
|
||||
|
||||
state.clock.advance(Duration::minutes(3));
|
||||
|
||||
let request = Request::post(format!(
|
||||
"/api/admin/v1/personal-sessions/{session_id}/regenerate"
|
||||
))
|
||||
.bearer(&token)
|
||||
.json(json!({
|
||||
"expires_in": 86400
|
||||
}));
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CREATED);
|
||||
|
||||
let body: Value = response.json();
|
||||
|
||||
assert_json_snapshot!(body, @r#"
|
||||
{
|
||||
"data": {
|
||||
"type": "personal-session",
|
||||
"id": "01FSHN9AG07HNEZXNQM2KNBNF6",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-16T14:40:00Z",
|
||||
"revoked_at": null,
|
||||
"owner_user_id": null,
|
||||
"owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR",
|
||||
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"human_name": "SuperDuperAdminCLITool Token",
|
||||
"scope": "openid urn:mas:admin",
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"expires_at": "2022-01-17T14:43:00Z",
|
||||
"access_token": "mpt_6cq7FqNSYoosbXl3bbpfh9yNy9NzuR_0vOV2O"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
|
||||
}
|
||||
}
|
||||
"#);
|
||||
}
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,8 @@
|
||||
// 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::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
// 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::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
// 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::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
// 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::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
// 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::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
// 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::{Json, response::IntoResponse};
|
||||
use axum_extra::extract::{Query, QueryRejection};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::record_error;
|
||||
|
||||
@@ -8,9 +8,10 @@ use std::collections::HashMap;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::{Form, Path, Query, State},
|
||||
extract::{Form, Path, State},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use chrono::Duration;
|
||||
use mas_axum_utils::{
|
||||
InternalError,
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{extract::State, response::IntoResponse};
|
||||
use axum_extra::extract::Query;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::{GenericError, InternalError};
|
||||
use mas_data_model::{BoxClock, BoxRng};
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
extract::State,
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use mas_axum_utils::{InternalError, cookies::CookieJar};
|
||||
use mas_data_model::BoxClock;
|
||||
use mas_router::UrlBuilder;
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum_extra::typed_header::TypedHeader;
|
||||
use axum::{Json, extract::State, response::IntoResponse};
|
||||
use axum_extra::{extract::Query, typed_header::TypedHeader};
|
||||
use headers::ContentType;
|
||||
use mas_router::UrlBuilder;
|
||||
use oauth2_types::webfinger::WebFingerResponse;
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar};
|
||||
use mas_data_model::{BoxClock, BoxRng, UpstreamOAuthProvider};
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
extract::State,
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use mas_axum_utils::{InternalError, cookies::CookieJar};
|
||||
use mas_data_model::{BoxClock, BoxRng};
|
||||
use mas_router::{PostAuthAction, UrlBuilder};
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use axum::{
|
||||
extract::{Form, Query, State},
|
||||
extract::{Form, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::typed_header::TypedHeader;
|
||||
use axum_extra::{extract::Query, typed_header::TypedHeader};
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::{
|
||||
InternalError, SessionInfoExt,
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
extract::State,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _};
|
||||
use mas_data_model::{BoxClock, BoxRng, SiteConfig};
|
||||
use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder};
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::{Form, Query, State},
|
||||
extract::{Form, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::typed_header::TypedHeader;
|
||||
use axum_extra::{extract::Query, typed_header::TypedHeader};
|
||||
use hyper::StatusCode;
|
||||
use lettre::Address;
|
||||
use mas_axum_utils::{
|
||||
|
||||
@@ -86,11 +86,13 @@ impl core::str::FromStr for ResponseMode {
|
||||
Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, SerializeDisplay, DeserializeFromStr,
|
||||
)]
|
||||
#[non_exhaustive]
|
||||
#[derive(Default)]
|
||||
pub enum Display {
|
||||
/// The Authorization Server should display the authentication and consent
|
||||
/// UI consistent with a full User Agent page view.
|
||||
///
|
||||
/// This is the default display mode.
|
||||
#[default]
|
||||
Page,
|
||||
|
||||
/// The Authorization Server should display the authentication and consent
|
||||
@@ -135,12 +137,6 @@ impl core::str::FromStr for Display {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Display {
|
||||
fn default() -> Self {
|
||||
Self::Page
|
||||
}
|
||||
}
|
||||
|
||||
/// Value that specifies whether the Authorization Server prompts the End-User
|
||||
/// for reauthentication and consent.
|
||||
///
|
||||
|
||||
14
crates/storage-pg/.sqlx/query-2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be.json
generated
Normal file
14
crates/storage-pg/.sqlx/query-2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM personal_access_tokens\n WHERE personal_session_id IN (\n SELECT personal_session_id\n FROM personal_sessions\n WHERE owner_oauth2_client_id = $1\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be"
|
||||
}
|
||||
15
crates/storage-pg/.sqlx/query-9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292.json
generated
Normal file
15
crates/storage-pg/.sqlx/query-9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE personal_access_tokens\n SET revoked_at = $2\n WHERE personal_session_id = $1 AND revoked_at IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292"
|
||||
}
|
||||
46
crates/storage-pg/.sqlx/query-d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3.json
generated
Normal file
46
crates/storage-pg/.sqlx/query-d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3.json
generated
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE personal_session_id = $1\n AND revoked_at IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "personal_access_token_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "personal_session_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "revoked_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3"
|
||||
}
|
||||
14
crates/storage-pg/.sqlx/query-dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e.json
generated
Normal file
14
crates/storage-pg/.sqlx/query-dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM personal_sessions\n WHERE owner_oauth2_client_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e"
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
null
|
||||
]
|
||||
|
||||
@@ -125,6 +125,18 @@ pub enum PersonalSessions {
|
||||
LastActiveIp,
|
||||
}
|
||||
|
||||
#[derive(sea_query::Iden)]
|
||||
#[iden = "personal_access_tokens"]
|
||||
pub enum PersonalAccessTokens {
|
||||
Table,
|
||||
PersonalAccessTokenId,
|
||||
PersonalSessionId,
|
||||
// AccessTokenSha256,
|
||||
CreatedAt,
|
||||
ExpiresAt,
|
||||
RevokedAt,
|
||||
}
|
||||
|
||||
#[derive(sea_query::Iden)]
|
||||
#[iden = "upstream_oauth_providers"]
|
||||
pub enum UpstreamOAuthProviders {
|
||||
|
||||
@@ -811,6 +811,49 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> {
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Delete any personal access tokens & sessions owned
|
||||
// by the client
|
||||
{
|
||||
let span = info_span!(
|
||||
"db.oauth2_client.delete_by_id.personal_access_tokens",
|
||||
{ DB_QUERY_TEXT } = tracing::field::Empty,
|
||||
);
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM personal_access_tokens
|
||||
WHERE personal_session_id IN (
|
||||
SELECT personal_session_id
|
||||
FROM personal_sessions
|
||||
WHERE owner_oauth2_client_id = $1
|
||||
)
|
||||
"#,
|
||||
Uuid::from(id),
|
||||
)
|
||||
.record(&span)
|
||||
.execute(&mut *self.conn)
|
||||
.instrument(span)
|
||||
.await?;
|
||||
}
|
||||
{
|
||||
let span = info_span!(
|
||||
"db.oauth2_client.delete_by_id.personal_sessions",
|
||||
{ DB_QUERY_TEXT } = tracing::field::Empty,
|
||||
);
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM personal_sessions
|
||||
WHERE owner_oauth2_client_id = $1
|
||||
"#,
|
||||
Uuid::from(id),
|
||||
)
|
||||
.record(&span)
|
||||
.execute(&mut *self.conn)
|
||||
.instrument(span)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Now delete the client itself
|
||||
let res = sqlx::query!(
|
||||
r#"
|
||||
|
||||
@@ -128,6 +128,43 @@ impl PersonalAccessTokenRepository for PgPersonalAccessTokenRepository<'_> {
|
||||
Ok(Some(res.into()))
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "db.personal_access_token.find_active_for_session",
|
||||
skip_all,
|
||||
fields(
|
||||
db.query.text,
|
||||
),
|
||||
err,
|
||||
)]
|
||||
async fn find_active_for_session(
|
||||
&mut self,
|
||||
session: &PersonalSession,
|
||||
) -> Result<Option<PersonalAccessToken>, Self::Error> {
|
||||
let res: Option<PersonalAccessTokenLookup> = sqlx::query_as!(
|
||||
PersonalAccessTokenLookup,
|
||||
r#"
|
||||
SELECT personal_access_token_id
|
||||
, personal_session_id
|
||||
, created_at
|
||||
, expires_at
|
||||
, revoked_at
|
||||
|
||||
FROM personal_access_tokens
|
||||
|
||||
WHERE personal_session_id = $1
|
||||
AND revoked_at IS NULL
|
||||
"#,
|
||||
Uuid::from(session.id),
|
||||
)
|
||||
.traced()
|
||||
.fetch_optional(&mut *self.conn)
|
||||
.await?;
|
||||
|
||||
let Some(res) = res else { return Ok(None) };
|
||||
|
||||
Ok(Some(res.into()))
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "db.personal_access_token.add",
|
||||
skip_all,
|
||||
|
||||
@@ -105,16 +105,16 @@ mod tests {
|
||||
|
||||
let full_list = repo.personal_session().list(all, pagination).await.unwrap();
|
||||
assert_eq!(full_list.edges.len(), 1);
|
||||
assert_eq!(full_list.edges[0].node.id, session.id);
|
||||
assert!(full_list.edges[0].node.is_valid());
|
||||
assert_eq!(full_list.edges[0].node.0.id, session.id);
|
||||
assert!(full_list.edges[0].node.0.is_valid());
|
||||
let active_list = repo
|
||||
.personal_session()
|
||||
.list(active, pagination)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(active_list.edges.len(), 1);
|
||||
assert_eq!(active_list.edges[0].node.id, session.id);
|
||||
assert!(active_list.edges[0].node.is_valid());
|
||||
assert_eq!(active_list.edges[0].node.0.id, session.id);
|
||||
assert!(active_list.edges[0].node.0.is_valid());
|
||||
let finished_list = repo
|
||||
.personal_session()
|
||||
.list(finished, pagination)
|
||||
@@ -154,7 +154,7 @@ mod tests {
|
||||
|
||||
let full_list = repo.personal_session().list(all, pagination).await.unwrap();
|
||||
assert_eq!(full_list.edges.len(), 1);
|
||||
assert_eq!(full_list.edges[0].node.id, session.id);
|
||||
assert_eq!(full_list.edges[0].node.0.id, session.id);
|
||||
let active_list = repo
|
||||
.personal_session()
|
||||
.list(active, pagination)
|
||||
@@ -167,8 +167,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(finished_list.edges.len(), 1);
|
||||
assert_eq!(finished_list.edges[0].node.id, session.id);
|
||||
assert!(finished_list.edges[0].node.is_revoked());
|
||||
assert_eq!(finished_list.edges[0].node.0.id, session.id);
|
||||
assert!(finished_list.edges[0].node.0.is_revoked());
|
||||
|
||||
// Reload the session and check again
|
||||
let session_lookup = repo
|
||||
|
||||
@@ -9,7 +9,10 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{
|
||||
Clock, User,
|
||||
personal::session::{PersonalSession, PersonalSessionOwner, SessionState},
|
||||
personal::{
|
||||
PersonalAccessToken,
|
||||
session::{PersonalSession, PersonalSessionOwner, SessionState},
|
||||
},
|
||||
};
|
||||
use mas_storage::{
|
||||
Page, Pagination,
|
||||
@@ -17,13 +20,15 @@ use mas_storage::{
|
||||
personal::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState},
|
||||
};
|
||||
use oauth2_types::scope::Scope;
|
||||
use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT;
|
||||
use rand::RngCore;
|
||||
use sea_query::{
|
||||
Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def,
|
||||
Cond, Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def,
|
||||
extension::postgres::PgExpr as _,
|
||||
};
|
||||
use sea_query_binder::SqlxBinder as _;
|
||||
use sqlx::PgConnection;
|
||||
use tracing::{Instrument as _, info_span};
|
||||
use ulid::Ulid;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -31,7 +36,7 @@ use crate::{
|
||||
DatabaseError,
|
||||
errors::DatabaseInconsistencyError,
|
||||
filter::{Filter, StatementExt as _},
|
||||
iden::PersonalSessions,
|
||||
iden::{PersonalAccessTokens, PersonalSessions},
|
||||
pagination::QueryBuilderExt as _,
|
||||
tracing::ExecuteExt as _,
|
||||
};
|
||||
@@ -116,6 +121,73 @@ impl TryFrom<PersonalSessionLookup> for PersonalSession {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
#[enum_def]
|
||||
struct PersonalSessionAndAccessTokenLookup {
|
||||
personal_session_id: Uuid,
|
||||
owner_user_id: Option<Uuid>,
|
||||
owner_oauth2_client_id: Option<Uuid>,
|
||||
actor_user_id: Uuid,
|
||||
human_name: String,
|
||||
scope_list: Vec<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
last_active_at: Option<DateTime<Utc>>,
|
||||
last_active_ip: Option<IpAddr>,
|
||||
|
||||
// tokens
|
||||
personal_access_token_id: Option<Uuid>,
|
||||
token_created_at: Option<DateTime<Utc>>,
|
||||
token_expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Node<Ulid> for PersonalSessionAndAccessTokenLookup {
|
||||
fn cursor(&self) -> Ulid {
|
||||
self.personal_session_id.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PersonalSessionAndAccessTokenLookup>
|
||||
for (PersonalSession, Option<PersonalAccessToken>)
|
||||
{
|
||||
type Error = DatabaseInconsistencyError;
|
||||
|
||||
fn try_from(value: PersonalSessionAndAccessTokenLookup) -> Result<Self, Self::Error> {
|
||||
let session = PersonalSession::try_from(PersonalSessionLookup {
|
||||
personal_session_id: value.personal_session_id,
|
||||
owner_user_id: value.owner_user_id,
|
||||
owner_oauth2_client_id: value.owner_oauth2_client_id,
|
||||
actor_user_id: value.actor_user_id,
|
||||
human_name: value.human_name,
|
||||
scope_list: value.scope_list,
|
||||
created_at: value.created_at,
|
||||
revoked_at: value.revoked_at,
|
||||
last_active_at: value.last_active_at,
|
||||
last_active_ip: value.last_active_ip,
|
||||
})?;
|
||||
|
||||
let token_opt = if let Some(id) = value.personal_access_token_id {
|
||||
let id = Ulid::from(id);
|
||||
Some(PersonalAccessToken {
|
||||
id,
|
||||
session_id: session.id,
|
||||
// should not be possible
|
||||
created_at: value.token_created_at.ok_or(
|
||||
DatabaseInconsistencyError::on("personal_sessions")
|
||||
.column("created_at")
|
||||
.row(id),
|
||||
)?,
|
||||
expires_at: value.token_expires_at,
|
||||
revoked_at: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok((session, token_opt))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
||||
type Error = DatabaseError;
|
||||
@@ -241,7 +313,30 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
||||
clock: &dyn Clock,
|
||||
session: PersonalSession,
|
||||
) -> Result<PersonalSession, Self::Error> {
|
||||
let finished_at = clock.now();
|
||||
let revoked_at = clock.now();
|
||||
|
||||
{
|
||||
// Revoke dependent PATs
|
||||
let span = info_span!(
|
||||
"db.personal_session.revoke.tokens",
|
||||
{ DB_QUERY_TEXT } = tracing::field::Empty,
|
||||
);
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE personal_access_tokens
|
||||
SET revoked_at = $2
|
||||
WHERE personal_session_id = $1 AND revoked_at IS NULL
|
||||
"#,
|
||||
Uuid::from(session.id),
|
||||
revoked_at,
|
||||
)
|
||||
.record(&span)
|
||||
.execute(&mut *self.conn)
|
||||
.instrument(span)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let res = sqlx::query!(
|
||||
r#"
|
||||
UPDATE personal_sessions
|
||||
@@ -249,7 +344,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
||||
WHERE personal_session_id = $1
|
||||
"#,
|
||||
Uuid::from(session.id),
|
||||
finished_at,
|
||||
revoked_at,
|
||||
)
|
||||
.traced()
|
||||
.execute(&mut *self.conn)
|
||||
@@ -258,7 +353,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
||||
DatabaseError::ensure_affected_rows(&res, 1)?;
|
||||
|
||||
session
|
||||
.finish(finished_at)
|
||||
.finish(revoked_at)
|
||||
.map_err(DatabaseError::to_invalid_operation)
|
||||
}
|
||||
|
||||
@@ -274,52 +369,82 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
||||
&mut self,
|
||||
filter: PersonalSessionFilter<'_>,
|
||||
pagination: Pagination,
|
||||
) -> Result<Page<PersonalSession>, Self::Error> {
|
||||
) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error> {
|
||||
let (sql, arguments) = Query::select()
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)),
|
||||
PersonalSessionLookupIden::PersonalSessionId,
|
||||
PersonalSessionAndAccessTokenLookupIden::PersonalSessionId,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)),
|
||||
PersonalSessionLookupIden::OwnerUserId,
|
||||
PersonalSessionAndAccessTokenLookupIden::OwnerUserId,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((
|
||||
PersonalSessions::Table,
|
||||
PersonalSessions::OwnerOAuth2ClientId,
|
||||
)),
|
||||
PersonalSessionLookupIden::OwnerOauth2ClientId,
|
||||
PersonalSessionAndAccessTokenLookupIden::OwnerOauth2ClientId,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)),
|
||||
PersonalSessionLookupIden::ActorUserId,
|
||||
PersonalSessionAndAccessTokenLookupIden::ActorUserId,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::HumanName)),
|
||||
PersonalSessionLookupIden::HumanName,
|
||||
PersonalSessionAndAccessTokenLookupIden::HumanName,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)),
|
||||
PersonalSessionLookupIden::ScopeList,
|
||||
PersonalSessionAndAccessTokenLookupIden::ScopeList,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::CreatedAt)),
|
||||
PersonalSessionLookupIden::CreatedAt,
|
||||
PersonalSessionAndAccessTokenLookupIden::CreatedAt,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)),
|
||||
PersonalSessionLookupIden::RevokedAt,
|
||||
PersonalSessionAndAccessTokenLookupIden::RevokedAt,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)),
|
||||
PersonalSessionLookupIden::LastActiveAt,
|
||||
PersonalSessionAndAccessTokenLookupIden::LastActiveAt,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveIp)),
|
||||
PersonalSessionLookupIden::LastActiveIp,
|
||||
PersonalSessionAndAccessTokenLookupIden::LastActiveIp,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((
|
||||
PersonalAccessTokens::Table,
|
||||
PersonalAccessTokens::PersonalAccessTokenId,
|
||||
)),
|
||||
PersonalSessionAndAccessTokenLookupIden::PersonalAccessTokenId,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::CreatedAt)),
|
||||
PersonalSessionAndAccessTokenLookupIden::TokenCreatedAt,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)),
|
||||
PersonalSessionAndAccessTokenLookupIden::TokenExpiresAt,
|
||||
)
|
||||
.from(PersonalSessions::Table)
|
||||
.left_join(
|
||||
PersonalAccessTokens::Table,
|
||||
Cond::all()
|
||||
.add(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId))
|
||||
.eq(Expr::col((
|
||||
PersonalAccessTokens::Table,
|
||||
PersonalAccessTokens::PersonalSessionId,
|
||||
))),
|
||||
)
|
||||
.add(
|
||||
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt))
|
||||
.is_null(),
|
||||
),
|
||||
)
|
||||
.apply_filter(filter)
|
||||
.generate_pagination(
|
||||
(PersonalSessions::Table, PersonalSessions::PersonalSessionId),
|
||||
@@ -327,7 +452,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
||||
)
|
||||
.build_sqlx(PostgresQueryBuilder);
|
||||
|
||||
let edges: Vec<PersonalSessionLookup> = sqlx::query_as_with(&sql, arguments)
|
||||
let edges: Vec<PersonalSessionAndAccessTokenLookup> = sqlx::query_as_with(&sql, arguments)
|
||||
.traced()
|
||||
.fetch_all(&mut *self.conn)
|
||||
.await?;
|
||||
@@ -349,6 +474,21 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
||||
let (sql, arguments) = Query::select()
|
||||
.expr(Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)).count())
|
||||
.from(PersonalSessions::Table)
|
||||
.left_join(
|
||||
PersonalAccessTokens::Table,
|
||||
Cond::all()
|
||||
.add(
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId))
|
||||
.eq(Expr::col((
|
||||
PersonalAccessTokens::Table,
|
||||
PersonalAccessTokens::PersonalSessionId,
|
||||
))),
|
||||
)
|
||||
.add(
|
||||
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt))
|
||||
.is_null(),
|
||||
),
|
||||
)
|
||||
.apply_filter(filter)
|
||||
.build_sqlx(PostgresQueryBuilder);
|
||||
|
||||
@@ -469,5 +609,23 @@ impl Filter for PersonalSessionFilter<'_> {
|
||||
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt))
|
||||
.gt(last_active_after)
|
||||
}))
|
||||
.add_option(self.expires_before().map(|expires_before| {
|
||||
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt))
|
||||
.lt(expires_before)
|
||||
}))
|
||||
.add_option(self.expires_after().map(|expires_after| {
|
||||
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt))
|
||||
.gt(expires_after)
|
||||
}))
|
||||
.add_option(self.expires().map(|expires| {
|
||||
let column =
|
||||
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt));
|
||||
|
||||
if expires {
|
||||
column.is_not_null()
|
||||
} else {
|
||||
column.is_null()
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,22 @@ pub trait PersonalAccessTokenRepository: Send + Sync {
|
||||
access_token: &str,
|
||||
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
||||
|
||||
/// Find the active access token belonging to a given session.
|
||||
///
|
||||
/// Returns the active access token if it exists, `None` otherwise
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `session`: The session to lookup
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Self::Error`] if the underlying repository fails
|
||||
async fn find_active_for_session(
|
||||
&mut self,
|
||||
session: &PersonalSession,
|
||||
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
||||
|
||||
/// Add a new access token to the database
|
||||
///
|
||||
/// Returns the newly created access token
|
||||
@@ -102,6 +118,11 @@ repository_impl!(PersonalAccessTokenRepository:
|
||||
access_token: &str,
|
||||
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
||||
|
||||
async fn find_active_for_session(
|
||||
&mut self,
|
||||
session: &PersonalSession,
|
||||
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
||||
|
||||
async fn add(
|
||||
&mut self,
|
||||
rng: &mut (dyn RngCore + Send),
|
||||
|
||||
@@ -9,7 +9,10 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{
|
||||
Client, Clock, Device, User,
|
||||
personal::session::{PersonalSession, PersonalSessionOwner},
|
||||
personal::{
|
||||
PersonalAccessToken,
|
||||
session::{PersonalSession, PersonalSessionOwner},
|
||||
},
|
||||
};
|
||||
use oauth2_types::scope::Scope;
|
||||
use rand_core::RngCore;
|
||||
@@ -99,7 +102,7 @@ pub trait PersonalSessionRepository: Send + Sync {
|
||||
&mut self,
|
||||
filter: PersonalSessionFilter<'_>,
|
||||
pagination: Pagination,
|
||||
) -> Result<Page<PersonalSession>, Self::Error>;
|
||||
) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error>;
|
||||
|
||||
/// Count [`PersonalSession`]s matching the given filter
|
||||
///
|
||||
@@ -151,7 +154,7 @@ repository_impl!(PersonalSessionRepository:
|
||||
&mut self,
|
||||
filter: PersonalSessionFilter<'_>,
|
||||
pagination: Pagination,
|
||||
) -> Result<Page<PersonalSession>, Self::Error>;
|
||||
) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error>;
|
||||
|
||||
async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result<usize, Self::Error>;
|
||||
|
||||
@@ -161,7 +164,8 @@ repository_impl!(PersonalSessionRepository:
|
||||
) -> Result<(), Self::Error>;
|
||||
);
|
||||
|
||||
/// Filter parameters for listing personal sessions
|
||||
/// Filter parameters for listing personal sessions alongside personal access
|
||||
/// tokens
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub struct PersonalSessionFilter<'a> {
|
||||
owner_user: Option<&'a User>,
|
||||
@@ -172,6 +176,9 @@ pub struct PersonalSessionFilter<'a> {
|
||||
scope: Option<&'a Scope>,
|
||||
last_active_before: Option<DateTime<Utc>>,
|
||||
last_active_after: Option<DateTime<Utc>>,
|
||||
expires_before: Option<DateTime<Utc>>,
|
||||
expires_after: Option<DateTime<Utc>>,
|
||||
expires: Option<bool>,
|
||||
}
|
||||
|
||||
/// Filter for what state a personal session is in.
|
||||
@@ -318,4 +325,50 @@ impl<'a> PersonalSessionFilter<'a> {
|
||||
pub fn device(&self) -> Option<&'a Device> {
|
||||
self.device
|
||||
}
|
||||
|
||||
/// Only return sessions whose access tokens expire before the given time
|
||||
#[must_use]
|
||||
pub fn with_expires_before(mut self, expires_before: DateTime<Utc>) -> Self {
|
||||
self.expires_before = Some(expires_before);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the expires before filter
|
||||
///
|
||||
/// Returns [`None`] if no expires before filter was set
|
||||
#[must_use]
|
||||
pub fn expires_before(&self) -> Option<DateTime<Utc>> {
|
||||
self.expires_before
|
||||
}
|
||||
|
||||
/// Only return sessions whose access tokens expire after the given time
|
||||
#[must_use]
|
||||
pub fn with_expires_after(mut self, expires_after: DateTime<Utc>) -> Self {
|
||||
self.expires_after = Some(expires_after);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the expires after filter
|
||||
///
|
||||
/// Returns [`None`] if no expires after filter was set
|
||||
#[must_use]
|
||||
pub fn expires_after(&self) -> Option<DateTime<Utc>> {
|
||||
self.expires_after
|
||||
}
|
||||
|
||||
/// Only return sessions whose access tokens have, or don't have,
|
||||
/// an expiry time set
|
||||
#[must_use]
|
||||
pub fn with_expires(mut self, expires: bool) -> Self {
|
||||
self.expires = Some(expires);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the expires filter
|
||||
///
|
||||
/// Returns [`None`] if no expires filter was set
|
||||
#[must_use]
|
||||
pub fn expires(&self) -> Option<bool> {
|
||||
self.expires
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,8 @@ impl RunnableJob for SyncDevicesJob {
|
||||
.map_err(JobError::retry)?;
|
||||
|
||||
for edge in page.edges {
|
||||
for scope in &*edge.node.scope {
|
||||
let (session, _) = &edge.node;
|
||||
for scope in &*session.scope {
|
||||
if let Some(device) = Device::from_scope_token(scope) {
|
||||
devices.insert(device.as_str().to_owned());
|
||||
}
|
||||
|
||||
@@ -896,6 +896,572 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/v1/personal-sessions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"personal-session"
|
||||
],
|
||||
"summary": "List personal sessions",
|
||||
"description": "Retrieve a list of personal sessions.\nNote that by default, all sessions, including revoked ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.",
|
||||
"operationId": "listPersonalSessions",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page[before]",
|
||||
"description": "Retrieve the items before the given ID",
|
||||
"schema": {
|
||||
"description": "Retrieve the items before the given ID",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page[after]",
|
||||
"description": "Retrieve the items after the given ID",
|
||||
"schema": {
|
||||
"description": "Retrieve the items after the given ID",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page[first]",
|
||||
"description": "Retrieve the first N items",
|
||||
"schema": {
|
||||
"description": "Retrieve the first N items",
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"minimum": 1.0,
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page[last]",
|
||||
"description": "Retrieve the last N items",
|
||||
"schema": {
|
||||
"description": "Retrieve the last N items",
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"minimum": 1.0,
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "count",
|
||||
"description": "Include the total number of items. Defaults to `true`.",
|
||||
"schema": {
|
||||
"description": "Include the total number of items. Defaults to `true`.",
|
||||
"$ref": "#/components/schemas/IncludeCount",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[owner_user]",
|
||||
"description": "Filter by owner user ID",
|
||||
"schema": {
|
||||
"description": "Filter by owner user ID",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[owner_client]",
|
||||
"description": "Filter by owner `OAuth2` client ID",
|
||||
"schema": {
|
||||
"description": "Filter by owner `OAuth2` client ID",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[actor_user]",
|
||||
"description": "Filter by actor user ID",
|
||||
"schema": {
|
||||
"description": "Filter by actor user ID",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[scope]",
|
||||
"description": "Retrieve the items with the given scope",
|
||||
"schema": {
|
||||
"description": "Retrieve the items with the given scope",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[status]",
|
||||
"description": "Filter by session status",
|
||||
"schema": {
|
||||
"description": "Filter by session status",
|
||||
"$ref": "#/components/schemas/PersonalSessionStatus",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[expires_before]",
|
||||
"description": "Filter by access token expiry date",
|
||||
"schema": {
|
||||
"description": "Filter by access token expiry date",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[expires_after]",
|
||||
"description": "Filter by access token expiry date",
|
||||
"schema": {
|
||||
"description": "Filter by access token expiry date",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[expires]",
|
||||
"description": "Filter by whether the access token has an expiry time",
|
||||
"schema": {
|
||||
"description": "Filter by whether the access token has an expiry time",
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
},
|
||||
"style": "form"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Paginated response of personal sessions",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PaginatedResponse_for_PersonalSession"
|
||||
},
|
||||
"example": {
|
||||
"meta": {
|
||||
"count": 3
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"type": "personal-session",
|
||||
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-16T13:00:00Z",
|
||||
"revoked_at": null,
|
||||
"owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"owner_client_id": null,
|
||||
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"human_name": "Alice's Development Token",
|
||||
"scope": "openid urn:matrix:org.matrix.msc2967.client:api:*",
|
||||
"last_active_at": "2022-01-16T15:30:00Z",
|
||||
"last_active_ip": "192.168.1.100",
|
||||
"expires_at": null
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||
},
|
||||
"meta": {
|
||||
"page": {
|
||||
"cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "personal-session",
|
||||
"id": "01FSHN9AG0BJ6AC5HQ9X6H4RP5",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-16T13:01:00Z",
|
||||
"revoked_at": "2022-01-16T16:20:00Z",
|
||||
"owner_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F",
|
||||
"owner_client_id": null,
|
||||
"actor_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F",
|
||||
"human_name": "Bob's Mobile App",
|
||||
"scope": "openid",
|
||||
"last_active_at": "2022-01-16T16:03:20Z",
|
||||
"last_active_ip": "10.0.0.50",
|
||||
"expires_at": null
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0BJ6AC5HQ9X6H4RP5"
|
||||
},
|
||||
"meta": {
|
||||
"page": {
|
||||
"cursor": "01FSHN9AG0BJ6AC5HQ9X6H4RP5"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "personal-session",
|
||||
"id": "01FSHN9AG0CJ6AC5HQ9X6H4RP6",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-16T13:02:00Z",
|
||||
"revoked_at": null,
|
||||
"owner_user_id": null,
|
||||
"owner_client_id": "01FSHN9AG0DJ6AC5HQ9X6H4RP7",
|
||||
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"human_name": "CI/CD Pipeline Token",
|
||||
"scope": "openid urn:mas:admin",
|
||||
"last_active_at": "2022-01-16T15:46:40Z",
|
||||
"last_active_ip": "203.0.113.10",
|
||||
"expires_at": "2022-01-24T04:36:40Z"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0CJ6AC5HQ9X6H4RP6"
|
||||
},
|
||||
"meta": {
|
||||
"page": {
|
||||
"cursor": "01FSHN9AG0CJ6AC5HQ9X6H4RP6"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions?page[first]=3",
|
||||
"first": "/api/admin/v1/personal-sessions?page[first]=3",
|
||||
"last": "/api/admin/v1/personal-sessions?page[last]=3",
|
||||
"next": "/api/admin/v1/personal-sessions?page[after]=01FSHN9AG0CJ6AC5HQ9X6H4RP6&page[first]=3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Client was not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "Client ID 00000000000000000000000000 not found"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"personal-session"
|
||||
],
|
||||
"summary": "Create a new personal session with personal access token",
|
||||
"operationId": "createPersonalSession",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePersonalSessionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Personal session and personal access token were created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid scope provided",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "Invalid scope"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User was not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "User not found"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/v1/personal-sessions/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"personal-session"
|
||||
],
|
||||
"summary": "Get a personal session",
|
||||
"operationId": "getPersonalSession",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "The ID of the resource",
|
||||
"$ref": "#/components/schemas/ULID"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Personal session details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
|
||||
},
|
||||
"example": {
|
||||
"data": {
|
||||
"type": "personal-session",
|
||||
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-16T13:00:00Z",
|
||||
"revoked_at": null,
|
||||
"owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"owner_client_id": null,
|
||||
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"human_name": "Alice's Development Token",
|
||||
"scope": "openid urn:matrix:org.matrix.msc2967.client:api:*",
|
||||
"last_active_at": "2022-01-16T15:30:00Z",
|
||||
"last_active_ip": "192.168.1.100",
|
||||
"expires_at": null
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Personal session not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "Personal session not found"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/v1/personal-sessions/{id}/revoke": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"personal-session"
|
||||
],
|
||||
"summary": "Revoke a personal session",
|
||||
"operationId": "revokePersonalSession",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "The ID of the resource",
|
||||
"$ref": "#/components/schemas/ULID"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Personal session was revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
|
||||
},
|
||||
"example": {
|
||||
"data": {
|
||||
"type": "personal-session",
|
||||
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
|
||||
"attributes": {
|
||||
"created_at": "2022-01-16T13:00:00Z",
|
||||
"revoked_at": null,
|
||||
"owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"owner_client_id": null,
|
||||
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||
"human_name": "Alice's Development Token",
|
||||
"scope": "openid urn:matrix:org.matrix.msc2967.client:api:*",
|
||||
"last_active_at": "2022-01-16T15:30:00Z",
|
||||
"last_active_ip": "192.168.1.100",
|
||||
"expires_at": null
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Personal session not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "Personal session with ID 00000000000000000000000000 not found"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Personal session already revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "Personal session with ID 00000000000000000000000000 is already revoked"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/v1/personal-sessions/{id}/regenerate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"personal-session"
|
||||
],
|
||||
"summary": "Regenerate a personal session by replacing its personal access token",
|
||||
"operationId": "regeneratePersonalSession",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "The ID of the resource",
|
||||
"$ref": "#/components/schemas/ULID"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegeneratePersonalSessionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Personal session was regenerated and a personal access token was created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User was not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "User not found"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/v1/policy-data": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -4579,6 +5145,249 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PersonalSessionFilter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter[owner_user]": {
|
||||
"description": "Filter by owner user ID",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"filter[owner_client]": {
|
||||
"description": "Filter by owner `OAuth2` client ID",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"filter[actor_user]": {
|
||||
"description": "Filter by actor user ID",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"filter[scope]": {
|
||||
"description": "Retrieve the items with the given scope",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"filter[status]": {
|
||||
"description": "Filter by session status",
|
||||
"$ref": "#/components/schemas/PersonalSessionStatus",
|
||||
"nullable": true
|
||||
},
|
||||
"filter[expires_before]": {
|
||||
"description": "Filter by access token expiry date",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"filter[expires_after]": {
|
||||
"description": "Filter by access token expiry date",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"filter[expires]": {
|
||||
"description": "Filter by whether the access token has an expiry time",
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"PersonalSessionStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"revoked"
|
||||
]
|
||||
},
|
||||
"PaginatedResponse_for_PersonalSession": {
|
||||
"description": "A top-level response with a page of resources",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"links"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"description": "Response metadata",
|
||||
"$ref": "#/components/schemas/PaginationMeta",
|
||||
"nullable": true
|
||||
},
|
||||
"data": {
|
||||
"description": "The list of resources",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SingleResource_for_PersonalSession"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"links": {
|
||||
"description": "Related links",
|
||||
"$ref": "#/components/schemas/PaginationLinks"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SingleResource_for_PersonalSession": {
|
||||
"description": "A single resource, with its type, ID, attributes and related links",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"attributes",
|
||||
"id",
|
||||
"links",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "The type of the resource",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "The ID of the resource",
|
||||
"$ref": "#/components/schemas/ULID"
|
||||
},
|
||||
"attributes": {
|
||||
"description": "The attributes of the resource",
|
||||
"$ref": "#/components/schemas/PersonalSession"
|
||||
},
|
||||
"links": {
|
||||
"description": "Related links",
|
||||
"$ref": "#/components/schemas/SelfLinks"
|
||||
},
|
||||
"meta": {
|
||||
"description": "Metadata about the resource",
|
||||
"$ref": "#/components/schemas/SingleResourceMeta",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"PersonalSession": {
|
||||
"description": "A personal session (session using personal access tokens)",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"actor_user_id",
|
||||
"created_at",
|
||||
"human_name",
|
||||
"scope"
|
||||
],
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"description": "When the session was created",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"revoked_at": {
|
||||
"description": "When the session was revoked, if applicable",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"owner_user_id": {
|
||||
"description": "The ID of the user who owns this session (if user-owned)",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"owner_client_id": {
|
||||
"description": "The ID of the `OAuth2` client that owns this session (if client-owned)",
|
||||
"$ref": "#/components/schemas/ULID",
|
||||
"nullable": true
|
||||
},
|
||||
"actor_user_id": {
|
||||
"description": "The ID of the user that the session acts on behalf of",
|
||||
"$ref": "#/components/schemas/ULID"
|
||||
},
|
||||
"human_name": {
|
||||
"description": "Human-readable name for the session",
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"description": "`OAuth2` scopes for this session",
|
||||
"type": "string"
|
||||
},
|
||||
"last_active_at": {
|
||||
"description": "When the session was last active",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"last_active_ip": {
|
||||
"description": "IP address of last activity",
|
||||
"type": "string",
|
||||
"format": "ip",
|
||||
"nullable": true
|
||||
},
|
||||
"expires_at": {
|
||||
"description": "When the current token for this session expires. The session will need to be regenerated, producing a new access token, after this time. None if the current token won't expire or if the session is revoked.",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"access_token": {
|
||||
"description": "The actual access token (only returned on creation)",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreatePersonalSessionRequest": {
|
||||
"title": "JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"actor_user_id",
|
||||
"human_name",
|
||||
"scope"
|
||||
],
|
||||
"properties": {
|
||||
"actor_user_id": {
|
||||
"description": "The user this session will act on behalf of",
|
||||
"$ref": "#/components/schemas/ULID"
|
||||
},
|
||||
"human_name": {
|
||||
"description": "Human-readable name for the session",
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"description": "`OAuth2` scopes for this session",
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"description": "Token expiry time in seconds. If not set, the token won't expire.",
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0,
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SingleResponse_for_PersonalSession": {
|
||||
"description": "A top-level response with a single resource",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data",
|
||||
"links"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/SingleResource_for_PersonalSession"
|
||||
},
|
||||
"links": {
|
||||
"$ref": "#/components/schemas/SelfLinks"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RegeneratePersonalSessionRequest": {
|
||||
"title": "JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expires_in": {
|
||||
"description": "Token expiry time in seconds. If not set, the token won't expire.",
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0,
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetPolicyDataRequest": {
|
||||
"title": "JSON payload for the `POST /api/admin/v1/policy-data`",
|
||||
"type": "object",
|
||||
|
||||
0
misc/update.sh
Normal file → Executable file
0
misc/update.sh
Normal file → Executable file
Reference in New Issue
Block a user