Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
34b3462
storage: introduce find_active_for_session for PATs
reivilibre Oct 16, 2025
98c765c
storage: include PATs alongside personal sessions
reivilibre Oct 20, 2025
353d234
When revoking a personal session, also revoke its PAT
reivilibre Oct 20, 2025
01c89cd
Delete owned PATs & personal sessions when pruning OAuth2 clients
reivilibre Oct 20, 2025
2e5b386
Add personal session data models to admin API
reivilibre Oct 20, 2025
1030ec9
Add personal sessions admin API
reivilibre Oct 20, 2025
4e70f83
Add Admin API to regenerate a personal session (getting a new PAT)
reivilibre Oct 20, 2025
1fc8145
drive-by clippy fixes
reivilibre Oct 20, 2025
30abb7c
drive-by formatting fixes
reivilibre Oct 20, 2025
4863026
drive-by update.sh chmod +x
reivilibre Oct 20, 2025
78b010d
find_active_by_session: take &PersonalSession
reivilibre Oct 21, 2025
52c04c1
Add `expires` filter to personal sessions list
reivilibre Oct 21, 2025
ba9fc35
Make `expires_in` u32 and (on regenerate) not default to the same as …
reivilibre Oct 21, 2025
6102a4b
Use Option<Ulid> in schemars
reivilibre Oct 21, 2025
cc57e33
axum_extra: enable `query` feature flag
reivilibre Oct 21, 2025
d516b3d
Add `scope` filter to personal sessions list
reivilibre Oct 21, 2025
a0c5583
fixup! Make `expires_in` u32 and (on regenerate) not default to the s…
reivilibre Oct 21, 2025
db3dcce
use axum_extract's version of Query everywhere
reivilibre Oct 21, 2025
8fb0caf
fixup! Add `expires` filter to personal sessions list
reivilibre Oct 21, 2025
dda3a49
(update JSONSchema)
reivilibre Oct 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ syn2mas = { path = "./crates/syn2mas", version = "=1.4.0-rc.1" }
# OpenAPI schema generation and validation
[workspace.dependencies.aide]
version = "0.14.2"
features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"]
features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"]

# An `Arc` that can be atomically updated
[workspace.dependencies.arc-swap]
Expand Down Expand Up @@ -101,7 +101,7 @@ version = "0.8.6"
# Extra utilities for Axum
[workspace.dependencies.axum-extra]
version = "0.10.3"
features = ["cookie-private", "cookie-key-expansion", "typed-header"]
features = ["cookie-private", "cookie-key-expansion", "typed-header", "query"]

# Axum macros
[workspace.dependencies.axum-macros]
Expand Down
2 changes: 2 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ disallowed-methods = [
disallowed-types = [
{ path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" },
{ path = "std::path::Path", reason = "use camino::Utf8Path instead" },
{ path = "axum::extract::Query", reason = "use axum_extra::extract::Query instead. The built-in version doesn't deserialise lists."},
{ path = "axum::extract::rejection::QueryRejection", reason = "use axum_extra::extract::QueryRejection instead"}
]
8 changes: 2 additions & 6 deletions crates/email/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ pub struct Transport {
inner: Arc<TransportInner>,
}

#[derive(Default)]
enum TransportInner {
#[default]
Blackhole,
Smtp(AsyncSmtpTransport<Tokio1Executor>),
Sendmail(AsyncSendmailTransport<Tokio1Executor>),
Expand Down Expand Up @@ -113,12 +115,6 @@ impl Transport {
}
}

impl Default for TransportInner {
fn default() -> Self {
Self::Blackhole
}
}

#[derive(Debug, Error)]
#[error(transparent)]
pub enum Error {
Expand Down
185 changes: 184 additions & 1 deletion crates/handlers/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@
use std::net::IpAddr;

use chrono::{DateTime, Utc};
use mas_data_model::Device;
use mas_data_model::{
Device,
personal::{
PersonalAccessToken as DataModelPersonalAccessToken,
session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner},
},
};
use schemars::JsonSchema;
use serde::Serialize;
use thiserror::Error;
use ulid::Ulid;
use url::Url;

Expand Down Expand Up @@ -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
}
}
6 changes: 2 additions & 4 deletions crates/handlers/src/admin/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,10 @@ use std::{borrow::Cow, num::NonZeroUsize};
use aide::OperationIo;
use axum::{
Json,
extract::{
FromRequestParts, Path, Query,
rejection::{PathRejection, QueryRejection},
},
extract::{FromRequestParts, Path, rejection::PathRejection},
response::IntoResponse,
};
use axum_extra::extract::{Query, QueryRejection};
use axum_macros::FromRequestParts;
use hyper::StatusCode;
use mas_storage::pagination::PaginationDirection;
Expand Down
7 changes: 2 additions & 5 deletions crates/handlers/src/admin/v1/compat_sessions/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@
// Please see LICENSE files in the repository root for full details.

use aide::{OperationIo, transform::TransformOperation};
use axum::{
Json,
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum::{Json, response::IntoResponse};
use axum_extra::extract::{Query, QueryRejection};
use axum_macros::FromRequestParts;
use hyper::StatusCode;
use mas_axum_utils::record_error;
Expand Down
33 changes: 33 additions & 0 deletions crates/handlers/src/admin/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::passwords::PasswordManager;

mod compat_sessions;
mod oauth2_sessions;
mod personal_sessions;
mod policy_data;
mod site_config;
mod upstream_oauth_links;
Expand Down Expand Up @@ -80,6 +81,38 @@ where
self::oauth2_sessions::finish_doc,
),
)
.api_route(
"/personal-sessions",
get_with(
self::personal_sessions::list,
self::personal_sessions::list_doc,
)
.post_with(
self::personal_sessions::add,
self::personal_sessions::add_doc,
),
)
.api_route(
"/personal-sessions/{id}",
get_with(
self::personal_sessions::get,
self::personal_sessions::get_doc,
),
)
.api_route(
"/personal-sessions/{id}/revoke",
post_with(
self::personal_sessions::revoke,
self::personal_sessions::revoke_doc,
),
)
.api_route(
"/personal-sessions/{id}/regenerate",
post_with(
self::personal_sessions::regenerate,
self::personal_sessions::regenerate_doc,
),
)
.api_route(
"/policy-data",
post_with(self::policy_data::set, self::policy_data::set_doc),
Expand Down
7 changes: 2 additions & 5 deletions crates/handlers/src/admin/v1/oauth2_sessions/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@
use std::str::FromStr;

use aide::{OperationIo, transform::TransformOperation};
use axum::{
Json,
extract::{Query, rejection::QueryRejection},
response::IntoResponse,
};
use axum::{Json, response::IntoResponse};
use axum_extra::extract::{Query, QueryRejection};
use axum_macros::FromRequestParts;
use hyper::StatusCode;
use mas_axum_utils::record_error;
Expand Down
Loading
Loading