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",
|
"axum-core",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"headers",
|
"headers",
|
||||||
"http",
|
"http",
|
||||||
@@ -622,6 +623,8 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_html_form",
|
||||||
|
"serde_path_to_error",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ syn2mas = { path = "./crates/syn2mas", version = "=1.4.1" }
|
|||||||
# OpenAPI schema generation and validation
|
# OpenAPI schema generation and validation
|
||||||
[workspace.dependencies.aide]
|
[workspace.dependencies.aide]
|
||||||
version = "0.14.2"
|
version = "0.14.2"
|
||||||
features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"]
|
features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"]
|
||||||
|
|
||||||
# An `Arc` that can be atomically updated
|
# An `Arc` that can be atomically updated
|
||||||
[workspace.dependencies.arc-swap]
|
[workspace.dependencies.arc-swap]
|
||||||
@@ -101,7 +101,7 @@ version = "0.8.6"
|
|||||||
# Extra utilities for Axum
|
# Extra utilities for Axum
|
||||||
[workspace.dependencies.axum-extra]
|
[workspace.dependencies.axum-extra]
|
||||||
version = "0.10.3"
|
version = "0.10.3"
|
||||||
features = ["cookie-private", "cookie-key-expansion", "typed-header"]
|
features = ["cookie-private", "cookie-key-expansion", "typed-header", "query"]
|
||||||
|
|
||||||
# Axum macros
|
# Axum macros
|
||||||
[workspace.dependencies.axum-macros]
|
[workspace.dependencies.axum-macros]
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ disallowed-methods = [
|
|||||||
disallowed-types = [
|
disallowed-types = [
|
||||||
{ path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" },
|
{ path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" },
|
||||||
{ path = "std::path::Path", reason = "use camino::Utf8Path instead" },
|
{ path = "std::path::Path", reason = "use camino::Utf8Path instead" },
|
||||||
|
{ path = "axum::extract::Query", reason = "use axum_extra::extract::Query instead. The built-in version doesn't deserialise lists."},
|
||||||
|
{ path = "axum::extract::rejection::QueryRejection", reason = "use axum_extra::extract::QueryRejection instead"}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ pub struct Transport {
|
|||||||
inner: Arc<TransportInner>,
|
inner: Arc<TransportInner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
enum TransportInner {
|
enum TransportInner {
|
||||||
|
#[default]
|
||||||
Blackhole,
|
Blackhole,
|
||||||
Smtp(AsyncSmtpTransport<Tokio1Executor>),
|
Smtp(AsyncSmtpTransport<Tokio1Executor>),
|
||||||
Sendmail(AsyncSendmailTransport<Tokio1Executor>),
|
Sendmail(AsyncSendmailTransport<Tokio1Executor>),
|
||||||
@@ -113,12 +115,6 @@ impl Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TransportInner {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Blackhole
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
|||||||
@@ -7,9 +7,16 @@
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use mas_data_model::Device;
|
use mas_data_model::{
|
||||||
|
Device,
|
||||||
|
personal::{
|
||||||
|
PersonalAccessToken as DataModelPersonalAccessToken,
|
||||||
|
session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner},
|
||||||
|
},
|
||||||
|
};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use thiserror::Error;
|
||||||
use ulid::Ulid;
|
use ulid::Ulid;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -771,3 +778,179 @@ impl UpstreamOAuthProvider {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An error that shouldn't happen in practice, but suggests database
|
||||||
|
/// inconsistency.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[error(
|
||||||
|
"personal session {session_id} in inconsistent state: not revoked but no valid access token"
|
||||||
|
)]
|
||||||
|
pub struct InconsistentPersonalSession {
|
||||||
|
pub session_id: Ulid,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: we don't expose a separate concept of personal access tokens to the
|
||||||
|
// admin API; we merge the relevant attributes into the personal session.
|
||||||
|
/// A personal session (session using personal access tokens)
|
||||||
|
#[derive(Serialize, JsonSchema)]
|
||||||
|
pub struct PersonalSession {
|
||||||
|
#[serde(skip)]
|
||||||
|
id: Ulid,
|
||||||
|
|
||||||
|
/// When the session was created
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
|
||||||
|
/// When the session was revoked, if applicable
|
||||||
|
revoked_at: Option<DateTime<Utc>>,
|
||||||
|
|
||||||
|
/// The ID of the user who owns this session (if user-owned)
|
||||||
|
#[schemars(with = "Option<super::schema::Ulid>")]
|
||||||
|
owner_user_id: Option<Ulid>,
|
||||||
|
|
||||||
|
/// The ID of the `OAuth2` client that owns this session (if client-owned)
|
||||||
|
#[schemars(with = "Option<super::schema::Ulid>")]
|
||||||
|
owner_client_id: Option<Ulid>,
|
||||||
|
|
||||||
|
/// The ID of the user that the session acts on behalf of
|
||||||
|
#[schemars(with = "super::schema::Ulid")]
|
||||||
|
actor_user_id: Ulid,
|
||||||
|
|
||||||
|
/// Human-readable name for the session
|
||||||
|
human_name: String,
|
||||||
|
|
||||||
|
/// `OAuth2` scopes for this session
|
||||||
|
scope: String,
|
||||||
|
|
||||||
|
/// When the session was last active
|
||||||
|
last_active_at: Option<DateTime<Utc>>,
|
||||||
|
|
||||||
|
/// IP address of last activity
|
||||||
|
last_active_ip: Option<IpAddr>,
|
||||||
|
|
||||||
|
/// When the current token for this session expires.
|
||||||
|
/// The session will need to be regenerated, producing a new access token,
|
||||||
|
/// after this time.
|
||||||
|
/// None if the current token won't expire or if the session is revoked.
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
|
|
||||||
|
/// The actual access token (only returned on creation)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl
|
||||||
|
TryFrom<(
|
||||||
|
DataModelPersonalSession,
|
||||||
|
Option<DataModelPersonalAccessToken>,
|
||||||
|
)> for PersonalSession
|
||||||
|
{
|
||||||
|
type Error = InconsistentPersonalSession;
|
||||||
|
|
||||||
|
fn try_from(
|
||||||
|
(session, token): (
|
||||||
|
DataModelPersonalSession,
|
||||||
|
Option<DataModelPersonalAccessToken>,
|
||||||
|
),
|
||||||
|
) -> Result<Self, InconsistentPersonalSession> {
|
||||||
|
let expires_at = if let Some(token) = token {
|
||||||
|
token.expires_at
|
||||||
|
} else {
|
||||||
|
if !session.is_revoked() {
|
||||||
|
// No active token, but the session is not revoked.
|
||||||
|
return Err(InconsistentPersonalSession {
|
||||||
|
session_id: session.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (owner_user_id, owner_client_id) = match session.owner {
|
||||||
|
PersonalSessionOwner::User(id) => (Some(id), None),
|
||||||
|
PersonalSessionOwner::OAuth2Client(id) => (None, Some(id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
id: session.id,
|
||||||
|
created_at: session.created_at,
|
||||||
|
revoked_at: session.revoked_at(),
|
||||||
|
owner_user_id,
|
||||||
|
owner_client_id,
|
||||||
|
actor_user_id: session.actor_user_id,
|
||||||
|
human_name: session.human_name,
|
||||||
|
scope: session.scope.to_string(),
|
||||||
|
last_active_at: session.last_active_at,
|
||||||
|
last_active_ip: session.last_active_ip,
|
||||||
|
expires_at,
|
||||||
|
// If relevant, the caller will populate using `with_token` afterwards.
|
||||||
|
access_token: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resource for PersonalSession {
|
||||||
|
const KIND: &'static str = "personal-session";
|
||||||
|
const PATH: &'static str = "/api/admin/v1/personal-sessions";
|
||||||
|
|
||||||
|
fn id(&self) -> Ulid {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersonalSession {
|
||||||
|
/// Sample personal sessions for documentation/testing
|
||||||
|
pub fn samples() -> [Self; 3] {
|
||||||
|
[
|
||||||
|
Self {
|
||||||
|
id: Ulid::from_string("01FSHN9AG0AJ6AC5HQ9X6H4RP4").unwrap(),
|
||||||
|
created_at: DateTime::from_timestamp(1_642_338_000, 0).unwrap(), /* 2022-01-16T14:
|
||||||
|
* 40:00Z */
|
||||||
|
revoked_at: None,
|
||||||
|
owner_user_id: Some(Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap()),
|
||||||
|
owner_client_id: None,
|
||||||
|
actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(),
|
||||||
|
human_name: "Alice's Development Token".to_owned(),
|
||||||
|
scope: "openid urn:matrix:org.matrix.msc2967.client:api:*".to_owned(),
|
||||||
|
last_active_at: Some(DateTime::from_timestamp(1_642_347_000, 0).unwrap()), /* 2022-01-16T17:10:00Z */
|
||||||
|
last_active_ip: Some("192.168.1.100".parse().unwrap()),
|
||||||
|
expires_at: None,
|
||||||
|
access_token: None,
|
||||||
|
},
|
||||||
|
Self {
|
||||||
|
id: Ulid::from_string("01FSHN9AG0BJ6AC5HQ9X6H4RP5").unwrap(),
|
||||||
|
created_at: DateTime::from_timestamp(1_642_338_060, 0).unwrap(), /* 2022-01-16T14:
|
||||||
|
* 41:00Z */
|
||||||
|
revoked_at: Some(DateTime::from_timestamp(1_642_350_000, 0).unwrap()), /* 2022-01-16T18:00:00Z */
|
||||||
|
owner_user_id: Some(Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap()),
|
||||||
|
owner_client_id: None,
|
||||||
|
actor_user_id: Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap(),
|
||||||
|
human_name: "Bob's Mobile App".to_owned(),
|
||||||
|
scope: "openid".to_owned(),
|
||||||
|
last_active_at: Some(DateTime::from_timestamp(1_642_349_000, 0).unwrap()), /* 2022-01-16T17:43:20Z */
|
||||||
|
last_active_ip: Some("10.0.0.50".parse().unwrap()),
|
||||||
|
expires_at: None,
|
||||||
|
access_token: None,
|
||||||
|
},
|
||||||
|
Self {
|
||||||
|
id: Ulid::from_string("01FSHN9AG0CJ6AC5HQ9X6H4RP6").unwrap(),
|
||||||
|
created_at: DateTime::from_timestamp(1_642_338_120, 0).unwrap(), /* 2022-01-16T14:
|
||||||
|
* 42:00Z */
|
||||||
|
revoked_at: None,
|
||||||
|
owner_user_id: None,
|
||||||
|
owner_client_id: Some(Ulid::from_string("01FSHN9AG0DJ6AC5HQ9X6H4RP7").unwrap()),
|
||||||
|
actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(),
|
||||||
|
human_name: "CI/CD Pipeline Token".to_owned(),
|
||||||
|
scope: "openid urn:mas:admin".to_owned(),
|
||||||
|
last_active_at: Some(DateTime::from_timestamp(1_642_348_000, 0).unwrap()), /* 2022-01-16T17:26:40Z */
|
||||||
|
last_active_ip: Some("203.0.113.10".parse().unwrap()),
|
||||||
|
expires_at: Some(DateTime::from_timestamp(1_642_999_000, 0).unwrap()),
|
||||||
|
access_token: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the actual token value (for use in creation responses)
|
||||||
|
pub fn with_token(mut self, access_token: String) -> Self {
|
||||||
|
self.access_token = Some(access_token);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ use std::{borrow::Cow, num::NonZeroUsize};
|
|||||||
use aide::OperationIo;
|
use aide::OperationIo;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{
|
extract::{FromRequestParts, Path, rejection::PathRejection},
|
||||||
FromRequestParts, Path, Query,
|
|
||||||
rejection::{PathRejection, QueryRejection},
|
|
||||||
},
|
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_storage::pagination::PaginationDirection;
|
use mas_storage::pagination::PaginationDirection;
|
||||||
|
|||||||
@@ -4,11 +4,8 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use aide::{OperationIo, transform::TransformOperation};
|
use aide::{OperationIo, transform::TransformOperation};
|
||||||
use axum::{
|
use axum::{Json, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
extract::{Query, rejection::QueryRejection},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::record_error;
|
use mas_axum_utils::record_error;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::passwords::PasswordManager;
|
|||||||
|
|
||||||
mod compat_sessions;
|
mod compat_sessions;
|
||||||
mod oauth2_sessions;
|
mod oauth2_sessions;
|
||||||
|
mod personal_sessions;
|
||||||
mod policy_data;
|
mod policy_data;
|
||||||
mod site_config;
|
mod site_config;
|
||||||
mod upstream_oauth_links;
|
mod upstream_oauth_links;
|
||||||
@@ -80,6 +81,38 @@ where
|
|||||||
self::oauth2_sessions::finish_doc,
|
self::oauth2_sessions::finish_doc,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.api_route(
|
||||||
|
"/personal-sessions",
|
||||||
|
get_with(
|
||||||
|
self::personal_sessions::list,
|
||||||
|
self::personal_sessions::list_doc,
|
||||||
|
)
|
||||||
|
.post_with(
|
||||||
|
self::personal_sessions::add,
|
||||||
|
self::personal_sessions::add_doc,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/personal-sessions/{id}",
|
||||||
|
get_with(
|
||||||
|
self::personal_sessions::get,
|
||||||
|
self::personal_sessions::get_doc,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/personal-sessions/{id}/revoke",
|
||||||
|
post_with(
|
||||||
|
self::personal_sessions::revoke,
|
||||||
|
self::personal_sessions::revoke_doc,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/personal-sessions/{id}/regenerate",
|
||||||
|
post_with(
|
||||||
|
self::personal_sessions::regenerate,
|
||||||
|
self::personal_sessions::regenerate_doc,
|
||||||
|
),
|
||||||
|
)
|
||||||
.api_route(
|
.api_route(
|
||||||
"/policy-data",
|
"/policy-data",
|
||||||
post_with(self::policy_data::set, self::policy_data::set_doc),
|
post_with(self::policy_data::set, self::policy_data::set_doc),
|
||||||
|
|||||||
@@ -7,11 +7,8 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use aide::{OperationIo, transform::TransformOperation};
|
use aide::{OperationIo, transform::TransformOperation};
|
||||||
use axum::{
|
use axum::{Json, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
extract::{Query, rejection::QueryRejection},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::record_error;
|
use mas_axum_utils::record_error;
|
||||||
|
|||||||
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.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use aide::{OperationIo, transform::TransformOperation};
|
use aide::{OperationIo, transform::TransformOperation};
|
||||||
use axum::{
|
use axum::{Json, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
extract::{Query, rejection::QueryRejection},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::record_error;
|
use mas_axum_utils::record_error;
|
||||||
|
|||||||
@@ -4,11 +4,8 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use aide::{OperationIo, transform::TransformOperation};
|
use aide::{OperationIo, transform::TransformOperation};
|
||||||
use axum::{
|
use axum::{Json, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
extract::{Query, rejection::QueryRejection},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::record_error;
|
use mas_axum_utils::record_error;
|
||||||
|
|||||||
@@ -4,11 +4,8 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use aide::{OperationIo, transform::TransformOperation};
|
use aide::{OperationIo, transform::TransformOperation};
|
||||||
use axum::{
|
use axum::{Json, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
extract::{Query, rejection::QueryRejection},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::record_error;
|
use mas_axum_utils::record_error;
|
||||||
|
|||||||
@@ -5,11 +5,8 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use aide::{OperationIo, transform::TransformOperation};
|
use aide::{OperationIo, transform::TransformOperation};
|
||||||
use axum::{
|
use axum::{Json, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
extract::{Query, rejection::QueryRejection},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::record_error;
|
use mas_axum_utils::record_error;
|
||||||
|
|||||||
@@ -4,11 +4,8 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use aide::{OperationIo, transform::TransformOperation};
|
use aide::{OperationIo, transform::TransformOperation};
|
||||||
use axum::{
|
use axum::{Json, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
extract::{Query, rejection::QueryRejection},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::record_error;
|
use mas_axum_utils::record_error;
|
||||||
|
|||||||
@@ -5,11 +5,8 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use aide::{OperationIo, transform::TransformOperation};
|
use aide::{OperationIo, transform::TransformOperation};
|
||||||
use axum::{
|
use axum::{Json, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::extract::{Query, QueryRejection};
|
||||||
extract::{Query, rejection::QueryRejection},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_macros::FromRequestParts;
|
use axum_macros::FromRequestParts;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::record_error;
|
use mas_axum_utils::record_error;
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Form, Path, Query, State},
|
extract::{Form, Path, State},
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::Query;
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
use mas_axum_utils::{
|
use mas_axum_utils::{
|
||||||
InternalError,
|
InternalError,
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use axum::{
|
use axum::{extract::State, response::IntoResponse};
|
||||||
extract::{Query, State},
|
use axum_extra::extract::Query;
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::{GenericError, InternalError};
|
use mas_axum_utils::{GenericError, InternalError};
|
||||||
use mas_data_model::{BoxClock, BoxRng};
|
use mas_data_model::{BoxClock, BoxRng};
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::State,
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::Query;
|
||||||
use mas_axum_utils::{InternalError, cookies::CookieJar};
|
use mas_axum_utils::{InternalError, cookies::CookieJar};
|
||||||
use mas_data_model::BoxClock;
|
use mas_data_model::BoxClock;
|
||||||
use mas_router::UrlBuilder;
|
use mas_router::UrlBuilder;
|
||||||
|
|||||||
@@ -4,12 +4,8 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use axum::{
|
use axum::{Json, extract::State, response::IntoResponse};
|
||||||
Json,
|
use axum_extra::{extract::Query, typed_header::TypedHeader};
|
||||||
extract::{Query, State},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_extra::typed_header::TypedHeader;
|
|
||||||
use headers::ContentType;
|
use headers::ContentType;
|
||||||
use mas_router::UrlBuilder;
|
use mas_router::UrlBuilder;
|
||||||
use oauth2_types::webfinger::WebFingerResponse;
|
use oauth2_types::webfinger::WebFingerResponse;
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, State},
|
||||||
response::{IntoResponse, Redirect},
|
response::{IntoResponse, Redirect},
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::Query;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar};
|
use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar};
|
||||||
use mas_data_model::{BoxClock, BoxRng, UpstreamOAuthProvider};
|
use mas_data_model::{BoxClock, BoxRng, UpstreamOAuthProvider};
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::State,
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::Query;
|
||||||
use mas_axum_utils::{InternalError, cookies::CookieJar};
|
use mas_axum_utils::{InternalError, cookies::CookieJar};
|
||||||
use mas_data_model::{BoxClock, BoxRng};
|
use mas_data_model::{BoxClock, BoxRng};
|
||||||
use mas_router::{PostAuthAction, UrlBuilder};
|
use mas_router::{PostAuthAction, UrlBuilder};
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Form, Query, State},
|
extract::{Form, State},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use axum_extra::typed_header::TypedHeader;
|
use axum_extra::{extract::Query, typed_header::TypedHeader};
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::{
|
use mas_axum_utils::{
|
||||||
InternalError, SessionInfoExt,
|
InternalError, SessionInfoExt,
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::State,
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::Query;
|
||||||
use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _};
|
use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _};
|
||||||
use mas_data_model::{BoxClock, BoxRng, SiteConfig};
|
use mas_data_model::{BoxClock, BoxRng, SiteConfig};
|
||||||
use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder};
|
use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder};
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
use std::{str::FromStr, sync::Arc};
|
use std::{str::FromStr, sync::Arc};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Form, Query, State},
|
extract::{Form, State},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use axum_extra::typed_header::TypedHeader;
|
use axum_extra::{extract::Query, typed_header::TypedHeader};
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use lettre::Address;
|
use lettre::Address;
|
||||||
use mas_axum_utils::{
|
use mas_axum_utils::{
|
||||||
|
|||||||
@@ -86,11 +86,13 @@ impl core::str::FromStr for ResponseMode {
|
|||||||
Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, SerializeDisplay, DeserializeFromStr,
|
Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, SerializeDisplay, DeserializeFromStr,
|
||||||
)]
|
)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum Display {
|
pub enum Display {
|
||||||
/// The Authorization Server should display the authentication and consent
|
/// The Authorization Server should display the authentication and consent
|
||||||
/// UI consistent with a full User Agent page view.
|
/// UI consistent with a full User Agent page view.
|
||||||
///
|
///
|
||||||
/// This is the default display mode.
|
/// This is the default display mode.
|
||||||
|
#[default]
|
||||||
Page,
|
Page,
|
||||||
|
|
||||||
/// The Authorization Server should display the authentication and consent
|
/// The Authorization Server should display the authentication and consent
|
||||||
@@ -135,12 +137,6 @@ impl core::str::FromStr for Display {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Display {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Page
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Value that specifies whether the Authorization Server prompts the End-User
|
/// Value that specifies whether the Authorization Server prompts the End-User
|
||||||
/// for reauthentication and consent.
|
/// for reauthentication and consent.
|
||||||
///
|
///
|
||||||
|
|||||||
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": []
|
"Left": []
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
true,
|
||||||
true,
|
true,
|
||||||
null
|
null
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -125,6 +125,18 @@ pub enum PersonalSessions {
|
|||||||
LastActiveIp,
|
LastActiveIp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sea_query::Iden)]
|
||||||
|
#[iden = "personal_access_tokens"]
|
||||||
|
pub enum PersonalAccessTokens {
|
||||||
|
Table,
|
||||||
|
PersonalAccessTokenId,
|
||||||
|
PersonalSessionId,
|
||||||
|
// AccessTokenSha256,
|
||||||
|
CreatedAt,
|
||||||
|
ExpiresAt,
|
||||||
|
RevokedAt,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(sea_query::Iden)]
|
#[derive(sea_query::Iden)]
|
||||||
#[iden = "upstream_oauth_providers"]
|
#[iden = "upstream_oauth_providers"]
|
||||||
pub enum UpstreamOAuthProviders {
|
pub enum UpstreamOAuthProviders {
|
||||||
|
|||||||
@@ -811,6 +811,49 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete any personal access tokens & sessions owned
|
||||||
|
// by the client
|
||||||
|
{
|
||||||
|
let span = info_span!(
|
||||||
|
"db.oauth2_client.delete_by_id.personal_access_tokens",
|
||||||
|
{ DB_QUERY_TEXT } = tracing::field::Empty,
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
DELETE FROM personal_access_tokens
|
||||||
|
WHERE personal_session_id IN (
|
||||||
|
SELECT personal_session_id
|
||||||
|
FROM personal_sessions
|
||||||
|
WHERE owner_oauth2_client_id = $1
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
Uuid::from(id),
|
||||||
|
)
|
||||||
|
.record(&span)
|
||||||
|
.execute(&mut *self.conn)
|
||||||
|
.instrument(span)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let span = info_span!(
|
||||||
|
"db.oauth2_client.delete_by_id.personal_sessions",
|
||||||
|
{ DB_QUERY_TEXT } = tracing::field::Empty,
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
DELETE FROM personal_sessions
|
||||||
|
WHERE owner_oauth2_client_id = $1
|
||||||
|
"#,
|
||||||
|
Uuid::from(id),
|
||||||
|
)
|
||||||
|
.record(&span)
|
||||||
|
.execute(&mut *self.conn)
|
||||||
|
.instrument(span)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
// Now delete the client itself
|
// Now delete the client itself
|
||||||
let res = sqlx::query!(
|
let res = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -128,6 +128,43 @@ impl PersonalAccessTokenRepository for PgPersonalAccessTokenRepository<'_> {
|
|||||||
Ok(Some(res.into()))
|
Ok(Some(res.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "db.personal_access_token.find_active_for_session",
|
||||||
|
skip_all,
|
||||||
|
fields(
|
||||||
|
db.query.text,
|
||||||
|
),
|
||||||
|
err,
|
||||||
|
)]
|
||||||
|
async fn find_active_for_session(
|
||||||
|
&mut self,
|
||||||
|
session: &PersonalSession,
|
||||||
|
) -> Result<Option<PersonalAccessToken>, Self::Error> {
|
||||||
|
let res: Option<PersonalAccessTokenLookup> = sqlx::query_as!(
|
||||||
|
PersonalAccessTokenLookup,
|
||||||
|
r#"
|
||||||
|
SELECT personal_access_token_id
|
||||||
|
, personal_session_id
|
||||||
|
, created_at
|
||||||
|
, expires_at
|
||||||
|
, revoked_at
|
||||||
|
|
||||||
|
FROM personal_access_tokens
|
||||||
|
|
||||||
|
WHERE personal_session_id = $1
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
"#,
|
||||||
|
Uuid::from(session.id),
|
||||||
|
)
|
||||||
|
.traced()
|
||||||
|
.fetch_optional(&mut *self.conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(res) = res else { return Ok(None) };
|
||||||
|
|
||||||
|
Ok(Some(res.into()))
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
name = "db.personal_access_token.add",
|
name = "db.personal_access_token.add",
|
||||||
skip_all,
|
skip_all,
|
||||||
|
|||||||
@@ -105,16 +105,16 @@ mod tests {
|
|||||||
|
|
||||||
let full_list = repo.personal_session().list(all, pagination).await.unwrap();
|
let full_list = repo.personal_session().list(all, pagination).await.unwrap();
|
||||||
assert_eq!(full_list.edges.len(), 1);
|
assert_eq!(full_list.edges.len(), 1);
|
||||||
assert_eq!(full_list.edges[0].node.id, session.id);
|
assert_eq!(full_list.edges[0].node.0.id, session.id);
|
||||||
assert!(full_list.edges[0].node.is_valid());
|
assert!(full_list.edges[0].node.0.is_valid());
|
||||||
let active_list = repo
|
let active_list = repo
|
||||||
.personal_session()
|
.personal_session()
|
||||||
.list(active, pagination)
|
.list(active, pagination)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(active_list.edges.len(), 1);
|
assert_eq!(active_list.edges.len(), 1);
|
||||||
assert_eq!(active_list.edges[0].node.id, session.id);
|
assert_eq!(active_list.edges[0].node.0.id, session.id);
|
||||||
assert!(active_list.edges[0].node.is_valid());
|
assert!(active_list.edges[0].node.0.is_valid());
|
||||||
let finished_list = repo
|
let finished_list = repo
|
||||||
.personal_session()
|
.personal_session()
|
||||||
.list(finished, pagination)
|
.list(finished, pagination)
|
||||||
@@ -154,7 +154,7 @@ mod tests {
|
|||||||
|
|
||||||
let full_list = repo.personal_session().list(all, pagination).await.unwrap();
|
let full_list = repo.personal_session().list(all, pagination).await.unwrap();
|
||||||
assert_eq!(full_list.edges.len(), 1);
|
assert_eq!(full_list.edges.len(), 1);
|
||||||
assert_eq!(full_list.edges[0].node.id, session.id);
|
assert_eq!(full_list.edges[0].node.0.id, session.id);
|
||||||
let active_list = repo
|
let active_list = repo
|
||||||
.personal_session()
|
.personal_session()
|
||||||
.list(active, pagination)
|
.list(active, pagination)
|
||||||
@@ -167,8 +167,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(finished_list.edges.len(), 1);
|
assert_eq!(finished_list.edges.len(), 1);
|
||||||
assert_eq!(finished_list.edges[0].node.id, session.id);
|
assert_eq!(finished_list.edges[0].node.0.id, session.id);
|
||||||
assert!(finished_list.edges[0].node.is_revoked());
|
assert!(finished_list.edges[0].node.0.is_revoked());
|
||||||
|
|
||||||
// Reload the session and check again
|
// Reload the session and check again
|
||||||
let session_lookup = repo
|
let session_lookup = repo
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use mas_data_model::{
|
use mas_data_model::{
|
||||||
Clock, User,
|
Clock, User,
|
||||||
personal::session::{PersonalSession, PersonalSessionOwner, SessionState},
|
personal::{
|
||||||
|
PersonalAccessToken,
|
||||||
|
session::{PersonalSession, PersonalSessionOwner, SessionState},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use mas_storage::{
|
use mas_storage::{
|
||||||
Page, Pagination,
|
Page, Pagination,
|
||||||
@@ -17,13 +20,15 @@ use mas_storage::{
|
|||||||
personal::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState},
|
personal::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState},
|
||||||
};
|
};
|
||||||
use oauth2_types::scope::Scope;
|
use oauth2_types::scope::Scope;
|
||||||
|
use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sea_query::{
|
use sea_query::{
|
||||||
Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def,
|
Cond, Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def,
|
||||||
extension::postgres::PgExpr as _,
|
extension::postgres::PgExpr as _,
|
||||||
};
|
};
|
||||||
use sea_query_binder::SqlxBinder as _;
|
use sea_query_binder::SqlxBinder as _;
|
||||||
use sqlx::PgConnection;
|
use sqlx::PgConnection;
|
||||||
|
use tracing::{Instrument as _, info_span};
|
||||||
use ulid::Ulid;
|
use ulid::Ulid;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -31,7 +36,7 @@ use crate::{
|
|||||||
DatabaseError,
|
DatabaseError,
|
||||||
errors::DatabaseInconsistencyError,
|
errors::DatabaseInconsistencyError,
|
||||||
filter::{Filter, StatementExt as _},
|
filter::{Filter, StatementExt as _},
|
||||||
iden::PersonalSessions,
|
iden::{PersonalAccessTokens, PersonalSessions},
|
||||||
pagination::QueryBuilderExt as _,
|
pagination::QueryBuilderExt as _,
|
||||||
tracing::ExecuteExt as _,
|
tracing::ExecuteExt as _,
|
||||||
};
|
};
|
||||||
@@ -116,6 +121,73 @@ impl TryFrom<PersonalSessionLookup> for PersonalSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
#[enum_def]
|
||||||
|
struct PersonalSessionAndAccessTokenLookup {
|
||||||
|
personal_session_id: Uuid,
|
||||||
|
owner_user_id: Option<Uuid>,
|
||||||
|
owner_oauth2_client_id: Option<Uuid>,
|
||||||
|
actor_user_id: Uuid,
|
||||||
|
human_name: String,
|
||||||
|
scope_list: Vec<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
revoked_at: Option<DateTime<Utc>>,
|
||||||
|
last_active_at: Option<DateTime<Utc>>,
|
||||||
|
last_active_ip: Option<IpAddr>,
|
||||||
|
|
||||||
|
// tokens
|
||||||
|
personal_access_token_id: Option<Uuid>,
|
||||||
|
token_created_at: Option<DateTime<Utc>>,
|
||||||
|
token_expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Node<Ulid> for PersonalSessionAndAccessTokenLookup {
|
||||||
|
fn cursor(&self) -> Ulid {
|
||||||
|
self.personal_session_id.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<PersonalSessionAndAccessTokenLookup>
|
||||||
|
for (PersonalSession, Option<PersonalAccessToken>)
|
||||||
|
{
|
||||||
|
type Error = DatabaseInconsistencyError;
|
||||||
|
|
||||||
|
fn try_from(value: PersonalSessionAndAccessTokenLookup) -> Result<Self, Self::Error> {
|
||||||
|
let session = PersonalSession::try_from(PersonalSessionLookup {
|
||||||
|
personal_session_id: value.personal_session_id,
|
||||||
|
owner_user_id: value.owner_user_id,
|
||||||
|
owner_oauth2_client_id: value.owner_oauth2_client_id,
|
||||||
|
actor_user_id: value.actor_user_id,
|
||||||
|
human_name: value.human_name,
|
||||||
|
scope_list: value.scope_list,
|
||||||
|
created_at: value.created_at,
|
||||||
|
revoked_at: value.revoked_at,
|
||||||
|
last_active_at: value.last_active_at,
|
||||||
|
last_active_ip: value.last_active_ip,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let token_opt = if let Some(id) = value.personal_access_token_id {
|
||||||
|
let id = Ulid::from(id);
|
||||||
|
Some(PersonalAccessToken {
|
||||||
|
id,
|
||||||
|
session_id: session.id,
|
||||||
|
// should not be possible
|
||||||
|
created_at: value.token_created_at.ok_or(
|
||||||
|
DatabaseInconsistencyError::on("personal_sessions")
|
||||||
|
.column("created_at")
|
||||||
|
.row(id),
|
||||||
|
)?,
|
||||||
|
expires_at: value.token_expires_at,
|
||||||
|
revoked_at: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((session, token_opt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
||||||
type Error = DatabaseError;
|
type Error = DatabaseError;
|
||||||
@@ -241,7 +313,30 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
|||||||
clock: &dyn Clock,
|
clock: &dyn Clock,
|
||||||
session: PersonalSession,
|
session: PersonalSession,
|
||||||
) -> Result<PersonalSession, Self::Error> {
|
) -> Result<PersonalSession, Self::Error> {
|
||||||
let finished_at = clock.now();
|
let revoked_at = clock.now();
|
||||||
|
|
||||||
|
{
|
||||||
|
// Revoke dependent PATs
|
||||||
|
let span = info_span!(
|
||||||
|
"db.personal_session.revoke.tokens",
|
||||||
|
{ DB_QUERY_TEXT } = tracing::field::Empty,
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
UPDATE personal_access_tokens
|
||||||
|
SET revoked_at = $2
|
||||||
|
WHERE personal_session_id = $1 AND revoked_at IS NULL
|
||||||
|
"#,
|
||||||
|
Uuid::from(session.id),
|
||||||
|
revoked_at,
|
||||||
|
)
|
||||||
|
.record(&span)
|
||||||
|
.execute(&mut *self.conn)
|
||||||
|
.instrument(span)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
let res = sqlx::query!(
|
let res = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
UPDATE personal_sessions
|
UPDATE personal_sessions
|
||||||
@@ -249,7 +344,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
|||||||
WHERE personal_session_id = $1
|
WHERE personal_session_id = $1
|
||||||
"#,
|
"#,
|
||||||
Uuid::from(session.id),
|
Uuid::from(session.id),
|
||||||
finished_at,
|
revoked_at,
|
||||||
)
|
)
|
||||||
.traced()
|
.traced()
|
||||||
.execute(&mut *self.conn)
|
.execute(&mut *self.conn)
|
||||||
@@ -258,7 +353,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
|||||||
DatabaseError::ensure_affected_rows(&res, 1)?;
|
DatabaseError::ensure_affected_rows(&res, 1)?;
|
||||||
|
|
||||||
session
|
session
|
||||||
.finish(finished_at)
|
.finish(revoked_at)
|
||||||
.map_err(DatabaseError::to_invalid_operation)
|
.map_err(DatabaseError::to_invalid_operation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,52 +369,82 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
|||||||
&mut self,
|
&mut self,
|
||||||
filter: PersonalSessionFilter<'_>,
|
filter: PersonalSessionFilter<'_>,
|
||||||
pagination: Pagination,
|
pagination: Pagination,
|
||||||
) -> Result<Page<PersonalSession>, Self::Error> {
|
) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error> {
|
||||||
let (sql, arguments) = Query::select()
|
let (sql, arguments) = Query::select()
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)),
|
||||||
PersonalSessionLookupIden::PersonalSessionId,
|
PersonalSessionAndAccessTokenLookupIden::PersonalSessionId,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)),
|
||||||
PersonalSessionLookupIden::OwnerUserId,
|
PersonalSessionAndAccessTokenLookupIden::OwnerUserId,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((
|
Expr::col((
|
||||||
PersonalSessions::Table,
|
PersonalSessions::Table,
|
||||||
PersonalSessions::OwnerOAuth2ClientId,
|
PersonalSessions::OwnerOAuth2ClientId,
|
||||||
)),
|
)),
|
||||||
PersonalSessionLookupIden::OwnerOauth2ClientId,
|
PersonalSessionAndAccessTokenLookupIden::OwnerOauth2ClientId,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)),
|
||||||
PersonalSessionLookupIden::ActorUserId,
|
PersonalSessionAndAccessTokenLookupIden::ActorUserId,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::HumanName)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::HumanName)),
|
||||||
PersonalSessionLookupIden::HumanName,
|
PersonalSessionAndAccessTokenLookupIden::HumanName,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)),
|
||||||
PersonalSessionLookupIden::ScopeList,
|
PersonalSessionAndAccessTokenLookupIden::ScopeList,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::CreatedAt)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::CreatedAt)),
|
||||||
PersonalSessionLookupIden::CreatedAt,
|
PersonalSessionAndAccessTokenLookupIden::CreatedAt,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)),
|
||||||
PersonalSessionLookupIden::RevokedAt,
|
PersonalSessionAndAccessTokenLookupIden::RevokedAt,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)),
|
||||||
PersonalSessionLookupIden::LastActiveAt,
|
PersonalSessionAndAccessTokenLookupIden::LastActiveAt,
|
||||||
)
|
)
|
||||||
.expr_as(
|
.expr_as(
|
||||||
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveIp)),
|
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveIp)),
|
||||||
PersonalSessionLookupIden::LastActiveIp,
|
PersonalSessionAndAccessTokenLookupIden::LastActiveIp,
|
||||||
|
)
|
||||||
|
.expr_as(
|
||||||
|
Expr::col((
|
||||||
|
PersonalAccessTokens::Table,
|
||||||
|
PersonalAccessTokens::PersonalAccessTokenId,
|
||||||
|
)),
|
||||||
|
PersonalSessionAndAccessTokenLookupIden::PersonalAccessTokenId,
|
||||||
|
)
|
||||||
|
.expr_as(
|
||||||
|
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::CreatedAt)),
|
||||||
|
PersonalSessionAndAccessTokenLookupIden::TokenCreatedAt,
|
||||||
|
)
|
||||||
|
.expr_as(
|
||||||
|
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)),
|
||||||
|
PersonalSessionAndAccessTokenLookupIden::TokenExpiresAt,
|
||||||
)
|
)
|
||||||
.from(PersonalSessions::Table)
|
.from(PersonalSessions::Table)
|
||||||
|
.left_join(
|
||||||
|
PersonalAccessTokens::Table,
|
||||||
|
Cond::all()
|
||||||
|
.add(
|
||||||
|
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId))
|
||||||
|
.eq(Expr::col((
|
||||||
|
PersonalAccessTokens::Table,
|
||||||
|
PersonalAccessTokens::PersonalSessionId,
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt))
|
||||||
|
.is_null(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.apply_filter(filter)
|
.apply_filter(filter)
|
||||||
.generate_pagination(
|
.generate_pagination(
|
||||||
(PersonalSessions::Table, PersonalSessions::PersonalSessionId),
|
(PersonalSessions::Table, PersonalSessions::PersonalSessionId),
|
||||||
@@ -327,7 +452,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
|||||||
)
|
)
|
||||||
.build_sqlx(PostgresQueryBuilder);
|
.build_sqlx(PostgresQueryBuilder);
|
||||||
|
|
||||||
let edges: Vec<PersonalSessionLookup> = sqlx::query_as_with(&sql, arguments)
|
let edges: Vec<PersonalSessionAndAccessTokenLookup> = sqlx::query_as_with(&sql, arguments)
|
||||||
.traced()
|
.traced()
|
||||||
.fetch_all(&mut *self.conn)
|
.fetch_all(&mut *self.conn)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -349,6 +474,21 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
|
|||||||
let (sql, arguments) = Query::select()
|
let (sql, arguments) = Query::select()
|
||||||
.expr(Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)).count())
|
.expr(Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)).count())
|
||||||
.from(PersonalSessions::Table)
|
.from(PersonalSessions::Table)
|
||||||
|
.left_join(
|
||||||
|
PersonalAccessTokens::Table,
|
||||||
|
Cond::all()
|
||||||
|
.add(
|
||||||
|
Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId))
|
||||||
|
.eq(Expr::col((
|
||||||
|
PersonalAccessTokens::Table,
|
||||||
|
PersonalAccessTokens::PersonalSessionId,
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt))
|
||||||
|
.is_null(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.apply_filter(filter)
|
.apply_filter(filter)
|
||||||
.build_sqlx(PostgresQueryBuilder);
|
.build_sqlx(PostgresQueryBuilder);
|
||||||
|
|
||||||
@@ -469,5 +609,23 @@ impl Filter for PersonalSessionFilter<'_> {
|
|||||||
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt))
|
Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt))
|
||||||
.gt(last_active_after)
|
.gt(last_active_after)
|
||||||
}))
|
}))
|
||||||
|
.add_option(self.expires_before().map(|expires_before| {
|
||||||
|
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt))
|
||||||
|
.lt(expires_before)
|
||||||
|
}))
|
||||||
|
.add_option(self.expires_after().map(|expires_after| {
|
||||||
|
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt))
|
||||||
|
.gt(expires_after)
|
||||||
|
}))
|
||||||
|
.add_option(self.expires().map(|expires| {
|
||||||
|
let column =
|
||||||
|
Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt));
|
||||||
|
|
||||||
|
if expires {
|
||||||
|
column.is_not_null()
|
||||||
|
} else {
|
||||||
|
column.is_null()
|
||||||
|
}
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,22 @@ pub trait PersonalAccessTokenRepository: Send + Sync {
|
|||||||
access_token: &str,
|
access_token: &str,
|
||||||
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
||||||
|
|
||||||
|
/// Find the active access token belonging to a given session.
|
||||||
|
///
|
||||||
|
/// Returns the active access token if it exists, `None` otherwise
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
///
|
||||||
|
/// * `session`: The session to lookup
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Self::Error`] if the underlying repository fails
|
||||||
|
async fn find_active_for_session(
|
||||||
|
&mut self,
|
||||||
|
session: &PersonalSession,
|
||||||
|
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
||||||
|
|
||||||
/// Add a new access token to the database
|
/// Add a new access token to the database
|
||||||
///
|
///
|
||||||
/// Returns the newly created access token
|
/// Returns the newly created access token
|
||||||
@@ -102,6 +118,11 @@ repository_impl!(PersonalAccessTokenRepository:
|
|||||||
access_token: &str,
|
access_token: &str,
|
||||||
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
||||||
|
|
||||||
|
async fn find_active_for_session(
|
||||||
|
&mut self,
|
||||||
|
session: &PersonalSession,
|
||||||
|
) -> Result<Option<PersonalAccessToken>, Self::Error>;
|
||||||
|
|
||||||
async fn add(
|
async fn add(
|
||||||
&mut self,
|
&mut self,
|
||||||
rng: &mut (dyn RngCore + Send),
|
rng: &mut (dyn RngCore + Send),
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use mas_data_model::{
|
use mas_data_model::{
|
||||||
Client, Clock, Device, User,
|
Client, Clock, Device, User,
|
||||||
personal::session::{PersonalSession, PersonalSessionOwner},
|
personal::{
|
||||||
|
PersonalAccessToken,
|
||||||
|
session::{PersonalSession, PersonalSessionOwner},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use oauth2_types::scope::Scope;
|
use oauth2_types::scope::Scope;
|
||||||
use rand_core::RngCore;
|
use rand_core::RngCore;
|
||||||
@@ -99,7 +102,7 @@ pub trait PersonalSessionRepository: Send + Sync {
|
|||||||
&mut self,
|
&mut self,
|
||||||
filter: PersonalSessionFilter<'_>,
|
filter: PersonalSessionFilter<'_>,
|
||||||
pagination: Pagination,
|
pagination: Pagination,
|
||||||
) -> Result<Page<PersonalSession>, Self::Error>;
|
) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error>;
|
||||||
|
|
||||||
/// Count [`PersonalSession`]s matching the given filter
|
/// Count [`PersonalSession`]s matching the given filter
|
||||||
///
|
///
|
||||||
@@ -151,7 +154,7 @@ repository_impl!(PersonalSessionRepository:
|
|||||||
&mut self,
|
&mut self,
|
||||||
filter: PersonalSessionFilter<'_>,
|
filter: PersonalSessionFilter<'_>,
|
||||||
pagination: Pagination,
|
pagination: Pagination,
|
||||||
) -> Result<Page<PersonalSession>, Self::Error>;
|
) -> Result<Page<(PersonalSession, Option<PersonalAccessToken>)>, Self::Error>;
|
||||||
|
|
||||||
async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result<usize, Self::Error>;
|
async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result<usize, Self::Error>;
|
||||||
|
|
||||||
@@ -161,7 +164,8 @@ repository_impl!(PersonalSessionRepository:
|
|||||||
) -> Result<(), Self::Error>;
|
) -> Result<(), Self::Error>;
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Filter parameters for listing personal sessions
|
/// Filter parameters for listing personal sessions alongside personal access
|
||||||
|
/// tokens
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
pub struct PersonalSessionFilter<'a> {
|
pub struct PersonalSessionFilter<'a> {
|
||||||
owner_user: Option<&'a User>,
|
owner_user: Option<&'a User>,
|
||||||
@@ -172,6 +176,9 @@ pub struct PersonalSessionFilter<'a> {
|
|||||||
scope: Option<&'a Scope>,
|
scope: Option<&'a Scope>,
|
||||||
last_active_before: Option<DateTime<Utc>>,
|
last_active_before: Option<DateTime<Utc>>,
|
||||||
last_active_after: Option<DateTime<Utc>>,
|
last_active_after: Option<DateTime<Utc>>,
|
||||||
|
expires_before: Option<DateTime<Utc>>,
|
||||||
|
expires_after: Option<DateTime<Utc>>,
|
||||||
|
expires: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter for what state a personal session is in.
|
/// Filter for what state a personal session is in.
|
||||||
@@ -318,4 +325,50 @@ impl<'a> PersonalSessionFilter<'a> {
|
|||||||
pub fn device(&self) -> Option<&'a Device> {
|
pub fn device(&self) -> Option<&'a Device> {
|
||||||
self.device
|
self.device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only return sessions whose access tokens expire before the given time
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_expires_before(mut self, expires_before: DateTime<Utc>) -> Self {
|
||||||
|
self.expires_before = Some(expires_before);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the expires before filter
|
||||||
|
///
|
||||||
|
/// Returns [`None`] if no expires before filter was set
|
||||||
|
#[must_use]
|
||||||
|
pub fn expires_before(&self) -> Option<DateTime<Utc>> {
|
||||||
|
self.expires_before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only return sessions whose access tokens expire after the given time
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_expires_after(mut self, expires_after: DateTime<Utc>) -> Self {
|
||||||
|
self.expires_after = Some(expires_after);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the expires after filter
|
||||||
|
///
|
||||||
|
/// Returns [`None`] if no expires after filter was set
|
||||||
|
#[must_use]
|
||||||
|
pub fn expires_after(&self) -> Option<DateTime<Utc>> {
|
||||||
|
self.expires_after
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only return sessions whose access tokens have, or don't have,
|
||||||
|
/// an expiry time set
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_expires(mut self, expires: bool) -> Self {
|
||||||
|
self.expires = Some(expires);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the expires filter
|
||||||
|
///
|
||||||
|
/// Returns [`None`] if no expires filter was set
|
||||||
|
#[must_use]
|
||||||
|
pub fn expires(&self) -> Option<bool> {
|
||||||
|
self.expires
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,8 @@ impl RunnableJob for SyncDevicesJob {
|
|||||||
.map_err(JobError::retry)?;
|
.map_err(JobError::retry)?;
|
||||||
|
|
||||||
for edge in page.edges {
|
for edge in page.edges {
|
||||||
for scope in &*edge.node.scope {
|
let (session, _) = &edge.node;
|
||||||
|
for scope in &*session.scope {
|
||||||
if let Some(device) = Device::from_scope_token(scope) {
|
if let Some(device) = Device::from_scope_token(scope) {
|
||||||
devices.insert(device.as_str().to_owned());
|
devices.insert(device.as_str().to_owned());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,6 +896,572 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/admin/v1/personal-sessions": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"personal-session"
|
||||||
|
],
|
||||||
|
"summary": "List personal sessions",
|
||||||
|
"description": "Retrieve a list of personal sessions.\nNote that by default, all sessions, including revoked ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.",
|
||||||
|
"operationId": "listPersonalSessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "page[before]",
|
||||||
|
"description": "Retrieve the items before the given ID",
|
||||||
|
"schema": {
|
||||||
|
"description": "Retrieve the items before the given ID",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "page[after]",
|
||||||
|
"description": "Retrieve the items after the given ID",
|
||||||
|
"schema": {
|
||||||
|
"description": "Retrieve the items after the given ID",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "page[first]",
|
||||||
|
"description": "Retrieve the first N items",
|
||||||
|
"schema": {
|
||||||
|
"description": "Retrieve the first N items",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint",
|
||||||
|
"minimum": 1.0,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "page[last]",
|
||||||
|
"description": "Retrieve the last N items",
|
||||||
|
"schema": {
|
||||||
|
"description": "Retrieve the last N items",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint",
|
||||||
|
"minimum": 1.0,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "count",
|
||||||
|
"description": "Include the total number of items. Defaults to `true`.",
|
||||||
|
"schema": {
|
||||||
|
"description": "Include the total number of items. Defaults to `true`.",
|
||||||
|
"$ref": "#/components/schemas/IncludeCount",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter[owner_user]",
|
||||||
|
"description": "Filter by owner user ID",
|
||||||
|
"schema": {
|
||||||
|
"description": "Filter by owner user ID",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter[owner_client]",
|
||||||
|
"description": "Filter by owner `OAuth2` client ID",
|
||||||
|
"schema": {
|
||||||
|
"description": "Filter by owner `OAuth2` client ID",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter[actor_user]",
|
||||||
|
"description": "Filter by actor user ID",
|
||||||
|
"schema": {
|
||||||
|
"description": "Filter by actor user ID",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter[scope]",
|
||||||
|
"description": "Retrieve the items with the given scope",
|
||||||
|
"schema": {
|
||||||
|
"description": "Retrieve the items with the given scope",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter[status]",
|
||||||
|
"description": "Filter by session status",
|
||||||
|
"schema": {
|
||||||
|
"description": "Filter by session status",
|
||||||
|
"$ref": "#/components/schemas/PersonalSessionStatus",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter[expires_before]",
|
||||||
|
"description": "Filter by access token expiry date",
|
||||||
|
"schema": {
|
||||||
|
"description": "Filter by access token expiry date",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter[expires_after]",
|
||||||
|
"description": "Filter by access token expiry date",
|
||||||
|
"schema": {
|
||||||
|
"description": "Filter by access token expiry date",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter[expires]",
|
||||||
|
"description": "Filter by whether the access token has an expiry time",
|
||||||
|
"schema": {
|
||||||
|
"description": "Filter by whether the access token has an expiry time",
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Paginated response of personal sessions",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PaginatedResponse_for_PersonalSession"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"meta": {
|
||||||
|
"count": 3
|
||||||
|
},
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"type": "personal-session",
|
||||||
|
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
|
||||||
|
"attributes": {
|
||||||
|
"created_at": "2022-01-16T13:00:00Z",
|
||||||
|
"revoked_at": null,
|
||||||
|
"owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||||
|
"owner_client_id": null,
|
||||||
|
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||||
|
"human_name": "Alice's Development Token",
|
||||||
|
"scope": "openid urn:matrix:org.matrix.msc2967.client:api:*",
|
||||||
|
"last_active_at": "2022-01-16T15:30:00Z",
|
||||||
|
"last_active_ip": "192.168.1.100",
|
||||||
|
"expires_at": null
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"page": {
|
||||||
|
"cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "personal-session",
|
||||||
|
"id": "01FSHN9AG0BJ6AC5HQ9X6H4RP5",
|
||||||
|
"attributes": {
|
||||||
|
"created_at": "2022-01-16T13:01:00Z",
|
||||||
|
"revoked_at": "2022-01-16T16:20:00Z",
|
||||||
|
"owner_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F",
|
||||||
|
"owner_client_id": null,
|
||||||
|
"actor_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F",
|
||||||
|
"human_name": "Bob's Mobile App",
|
||||||
|
"scope": "openid",
|
||||||
|
"last_active_at": "2022-01-16T16:03:20Z",
|
||||||
|
"last_active_ip": "10.0.0.50",
|
||||||
|
"expires_at": null
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0BJ6AC5HQ9X6H4RP5"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"page": {
|
||||||
|
"cursor": "01FSHN9AG0BJ6AC5HQ9X6H4RP5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "personal-session",
|
||||||
|
"id": "01FSHN9AG0CJ6AC5HQ9X6H4RP6",
|
||||||
|
"attributes": {
|
||||||
|
"created_at": "2022-01-16T13:02:00Z",
|
||||||
|
"revoked_at": null,
|
||||||
|
"owner_user_id": null,
|
||||||
|
"owner_client_id": "01FSHN9AG0DJ6AC5HQ9X6H4RP7",
|
||||||
|
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||||
|
"human_name": "CI/CD Pipeline Token",
|
||||||
|
"scope": "openid urn:mas:admin",
|
||||||
|
"last_active_at": "2022-01-16T15:46:40Z",
|
||||||
|
"last_active_ip": "203.0.113.10",
|
||||||
|
"expires_at": "2022-01-24T04:36:40Z"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0CJ6AC5HQ9X6H4RP6"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"page": {
|
||||||
|
"cursor": "01FSHN9AG0CJ6AC5HQ9X6H4RP6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/personal-sessions?page[first]=3",
|
||||||
|
"first": "/api/admin/v1/personal-sessions?page[first]=3",
|
||||||
|
"last": "/api/admin/v1/personal-sessions?page[last]=3",
|
||||||
|
"next": "/api/admin/v1/personal-sessions?page[after]=01FSHN9AG0CJ6AC5HQ9X6H4RP6&page[first]=3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Client was not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "Client ID 00000000000000000000000000 not found"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"personal-session"
|
||||||
|
],
|
||||||
|
"summary": "Create a new personal session with personal access token",
|
||||||
|
"operationId": "createPersonalSession",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CreatePersonalSessionRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Personal session and personal access token were created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid scope provided",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "Invalid scope"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "User was not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "User not found"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/admin/v1/personal-sessions/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"personal-session"
|
||||||
|
],
|
||||||
|
"summary": "Get a personal session",
|
||||||
|
"operationId": "getPersonalSession",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"title": "The ID of the resource",
|
||||||
|
"$ref": "#/components/schemas/ULID"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Personal session details",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"data": {
|
||||||
|
"type": "personal-session",
|
||||||
|
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
|
||||||
|
"attributes": {
|
||||||
|
"created_at": "2022-01-16T13:00:00Z",
|
||||||
|
"revoked_at": null,
|
||||||
|
"owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||||
|
"owner_client_id": null,
|
||||||
|
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||||
|
"human_name": "Alice's Development Token",
|
||||||
|
"scope": "openid urn:matrix:org.matrix.msc2967.client:api:*",
|
||||||
|
"last_active_at": "2022-01-16T15:30:00Z",
|
||||||
|
"last_active_ip": "192.168.1.100",
|
||||||
|
"expires_at": null
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Personal session not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "Personal session not found"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/admin/v1/personal-sessions/{id}/revoke": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"personal-session"
|
||||||
|
],
|
||||||
|
"summary": "Revoke a personal session",
|
||||||
|
"operationId": "revokePersonalSession",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"title": "The ID of the resource",
|
||||||
|
"$ref": "#/components/schemas/ULID"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Personal session was revoked",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"data": {
|
||||||
|
"type": "personal-session",
|
||||||
|
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
|
||||||
|
"attributes": {
|
||||||
|
"created_at": "2022-01-16T13:00:00Z",
|
||||||
|
"revoked_at": null,
|
||||||
|
"owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||||
|
"owner_client_id": null,
|
||||||
|
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
|
||||||
|
"human_name": "Alice's Development Token",
|
||||||
|
"scope": "openid urn:matrix:org.matrix.msc2967.client:api:*",
|
||||||
|
"last_active_at": "2022-01-16T15:30:00Z",
|
||||||
|
"last_active_ip": "192.168.1.100",
|
||||||
|
"expires_at": null
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Personal session not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "Personal session with ID 00000000000000000000000000 not found"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Personal session already revoked",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "Personal session with ID 00000000000000000000000000 is already revoked"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/admin/v1/personal-sessions/{id}/regenerate": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"personal-session"
|
||||||
|
],
|
||||||
|
"summary": "Regenerate a personal session by replacing its personal access token",
|
||||||
|
"operationId": "regeneratePersonalSession",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"title": "The ID of the resource",
|
||||||
|
"$ref": "#/components/schemas/ULID"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RegeneratePersonalSessionRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Personal session was regenerated and a personal access token was created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SingleResponse_for_PersonalSession"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "User was not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "User not found"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/admin/v1/policy-data": {
|
"/api/admin/v1/policy-data": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4579,6 +5145,249 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PersonalSessionFilter": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filter[owner_user]": {
|
||||||
|
"description": "Filter by owner user ID",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"filter[owner_client]": {
|
||||||
|
"description": "Filter by owner `OAuth2` client ID",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"filter[actor_user]": {
|
||||||
|
"description": "Filter by actor user ID",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"filter[scope]": {
|
||||||
|
"description": "Retrieve the items with the given scope",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter[status]": {
|
||||||
|
"description": "Filter by session status",
|
||||||
|
"$ref": "#/components/schemas/PersonalSessionStatus",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"filter[expires_before]": {
|
||||||
|
"description": "Filter by access token expiry date",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"filter[expires_after]": {
|
||||||
|
"description": "Filter by access token expiry date",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"filter[expires]": {
|
||||||
|
"description": "Filter by whether the access token has an expiry time",
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PersonalSessionStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"active",
|
||||||
|
"revoked"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PaginatedResponse_for_PersonalSession": {
|
||||||
|
"description": "A top-level response with a page of resources",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"links"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"meta": {
|
||||||
|
"description": "Response metadata",
|
||||||
|
"$ref": "#/components/schemas/PaginationMeta",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"description": "The list of resources",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SingleResource_for_PersonalSession"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"description": "Related links",
|
||||||
|
"$ref": "#/components/schemas/PaginationLinks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SingleResource_for_PersonalSession": {
|
||||||
|
"description": "A single resource, with its type, ID, attributes and related links",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"attributes",
|
||||||
|
"id",
|
||||||
|
"links",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"description": "The type of the resource",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "The ID of the resource",
|
||||||
|
"$ref": "#/components/schemas/ULID"
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"description": "The attributes of the resource",
|
||||||
|
"$ref": "#/components/schemas/PersonalSession"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"description": "Related links",
|
||||||
|
"$ref": "#/components/schemas/SelfLinks"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"description": "Metadata about the resource",
|
||||||
|
"$ref": "#/components/schemas/SingleResourceMeta",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PersonalSession": {
|
||||||
|
"description": "A personal session (session using personal access tokens)",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"actor_user_id",
|
||||||
|
"created_at",
|
||||||
|
"human_name",
|
||||||
|
"scope"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"description": "When the session was created",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"description": "When the session was revoked, if applicable",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"owner_user_id": {
|
||||||
|
"description": "The ID of the user who owns this session (if user-owned)",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"owner_client_id": {
|
||||||
|
"description": "The ID of the `OAuth2` client that owns this session (if client-owned)",
|
||||||
|
"$ref": "#/components/schemas/ULID",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"actor_user_id": {
|
||||||
|
"description": "The ID of the user that the session acts on behalf of",
|
||||||
|
"$ref": "#/components/schemas/ULID"
|
||||||
|
},
|
||||||
|
"human_name": {
|
||||||
|
"description": "Human-readable name for the session",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"description": "`OAuth2` scopes for this session",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"last_active_at": {
|
||||||
|
"description": "When the session was last active",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"last_active_ip": {
|
||||||
|
"description": "IP address of last activity",
|
||||||
|
"type": "string",
|
||||||
|
"format": "ip",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"description": "When the current token for this session expires. The session will need to be regenerated, producing a new access token, after this time. None if the current token won't expire or if the session is revoked.",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"description": "The actual access token (only returned on creation)",
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CreatePersonalSessionRequest": {
|
||||||
|
"title": "JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"actor_user_id",
|
||||||
|
"human_name",
|
||||||
|
"scope"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"actor_user_id": {
|
||||||
|
"description": "The user this session will act on behalf of",
|
||||||
|
"$ref": "#/components/schemas/ULID"
|
||||||
|
},
|
||||||
|
"human_name": {
|
||||||
|
"description": "Human-readable name for the session",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"description": "`OAuth2` scopes for this session",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expires_in": {
|
||||||
|
"description": "Token expiry time in seconds. If not set, the token won't expire.",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SingleResponse_for_PersonalSession": {
|
||||||
|
"description": "A top-level response with a single resource",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"links"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/SingleResource_for_PersonalSession"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"$ref": "#/components/schemas/SelfLinks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RegeneratePersonalSessionRequest": {
|
||||||
|
"title": "JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expires_in": {
|
||||||
|
"description": "Token expiry time in seconds. If not set, the token won't expire.",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SetPolicyDataRequest": {
|
"SetPolicyDataRequest": {
|
||||||
"title": "JSON payload for the `POST /api/admin/v1/policy-data`",
|
"title": "JSON payload for the `POST /api/admin/v1/policy-data`",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
0
misc/update.sh
Normal file → Executable file
0
misc/update.sh
Normal file → Executable file
Reference in New Issue
Block a user