Skip to content

Commit eeba7e1

Browse files
authored
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.
2 parents 5fa9725 + dda3a49 commit eeba7e1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+3075
-109
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ syn2mas = { path = "./crates/syn2mas", version = "=1.4.1" }
6767
# OpenAPI schema generation and validation
6868
[workspace.dependencies.aide]
6969
version = "0.14.2"
70-
features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"]
70+
features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"]
7171

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

106106
# Axum macros
107107
[workspace.dependencies.axum-macros]

clippy.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ disallowed-methods = [
1717
disallowed-types = [
1818
{ path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" },
1919
{ path = "std::path::Path", reason = "use camino::Utf8Path instead" },
20+
{ path = "axum::extract::Query", reason = "use axum_extra::extract::Query instead. The built-in version doesn't deserialise lists."},
21+
{ path = "axum::extract::rejection::QueryRejection", reason = "use axum_extra::extract::QueryRejection instead"}
2022
]

crates/email/src/transport.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ pub struct Transport {
3636
inner: Arc<TransportInner>,
3737
}
3838

39+
#[derive(Default)]
3940
enum TransportInner {
41+
#[default]
4042
Blackhole,
4143
Smtp(AsyncSmtpTransport<Tokio1Executor>),
4244
Sendmail(AsyncSendmailTransport<Tokio1Executor>),
@@ -113,12 +115,6 @@ impl Transport {
113115
}
114116
}
115117

116-
impl Default for TransportInner {
117-
fn default() -> Self {
118-
Self::Blackhole
119-
}
120-
}
121-
122118
#[derive(Debug, Error)]
123119
#[error(transparent)]
124120
pub enum Error {

crates/handlers/src/admin/model.rs

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@
77
use std::net::IpAddr;
88

99
use chrono::{DateTime, Utc};
10-
use mas_data_model::Device;
10+
use mas_data_model::{
11+
Device,
12+
personal::{
13+
PersonalAccessToken as DataModelPersonalAccessToken,
14+
session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner},
15+
},
16+
};
1117
use schemars::JsonSchema;
1218
use serde::Serialize;
19+
use thiserror::Error;
1320
use ulid::Ulid;
1421
use url::Url;
1522

@@ -771,3 +778,179 @@ impl UpstreamOAuthProvider {
771778
]
772779
}
773780
}
781+
782+
/// An error that shouldn't happen in practice, but suggests database
783+
/// inconsistency.
784+
#[derive(Debug, Error)]
785+
#[error(
786+
"personal session {session_id} in inconsistent state: not revoked but no valid access token"
787+
)]
788+
pub struct InconsistentPersonalSession {
789+
pub session_id: Ulid,
790+
}
791+
792+
// Note: we don't expose a separate concept of personal access tokens to the
793+
// admin API; we merge the relevant attributes into the personal session.
794+
/// A personal session (session using personal access tokens)
795+
#[derive(Serialize, JsonSchema)]
796+
pub struct PersonalSession {
797+
#[serde(skip)]
798+
id: Ulid,
799+
800+
/// When the session was created
801+
created_at: DateTime<Utc>,
802+
803+
/// When the session was revoked, if applicable
804+
revoked_at: Option<DateTime<Utc>>,
805+
806+
/// The ID of the user who owns this session (if user-owned)
807+
#[schemars(with = "Option<super::schema::Ulid>")]
808+
owner_user_id: Option<Ulid>,
809+
810+
/// The ID of the `OAuth2` client that owns this session (if client-owned)
811+
#[schemars(with = "Option<super::schema::Ulid>")]
812+
owner_client_id: Option<Ulid>,
813+
814+
/// The ID of the user that the session acts on behalf of
815+
#[schemars(with = "super::schema::Ulid")]
816+
actor_user_id: Ulid,
817+
818+
/// Human-readable name for the session
819+
human_name: String,
820+
821+
/// `OAuth2` scopes for this session
822+
scope: String,
823+
824+
/// When the session was last active
825+
last_active_at: Option<DateTime<Utc>>,
826+
827+
/// IP address of last activity
828+
last_active_ip: Option<IpAddr>,
829+
830+
/// When the current token for this session expires.
831+
/// The session will need to be regenerated, producing a new access token,
832+
/// after this time.
833+
/// None if the current token won't expire or if the session is revoked.
834+
expires_at: Option<DateTime<Utc>>,
835+
836+
/// The actual access token (only returned on creation)
837+
#[serde(skip_serializing_if = "Option::is_none")]
838+
access_token: Option<String>,
839+
}
840+
841+
impl
842+
TryFrom<(
843+
DataModelPersonalSession,
844+
Option<DataModelPersonalAccessToken>,
845+
)> for PersonalSession
846+
{
847+
type Error = InconsistentPersonalSession;
848+
849+
fn try_from(
850+
(session, token): (
851+
DataModelPersonalSession,
852+
Option<DataModelPersonalAccessToken>,
853+
),
854+
) -> Result<Self, InconsistentPersonalSession> {
855+
let expires_at = if let Some(token) = token {
856+
token.expires_at
857+
} else {
858+
if !session.is_revoked() {
859+
// No active token, but the session is not revoked.
860+
return Err(InconsistentPersonalSession {
861+
session_id: session.id,
862+
});
863+
}
864+
None
865+
};
866+
867+
let (owner_user_id, owner_client_id) = match session.owner {
868+
PersonalSessionOwner::User(id) => (Some(id), None),
869+
PersonalSessionOwner::OAuth2Client(id) => (None, Some(id)),
870+
};
871+
872+
Ok(Self {
873+
id: session.id,
874+
created_at: session.created_at,
875+
revoked_at: session.revoked_at(),
876+
owner_user_id,
877+
owner_client_id,
878+
actor_user_id: session.actor_user_id,
879+
human_name: session.human_name,
880+
scope: session.scope.to_string(),
881+
last_active_at: session.last_active_at,
882+
last_active_ip: session.last_active_ip,
883+
expires_at,
884+
// If relevant, the caller will populate using `with_token` afterwards.
885+
access_token: None,
886+
})
887+
}
888+
}
889+
890+
impl Resource for PersonalSession {
891+
const KIND: &'static str = "personal-session";
892+
const PATH: &'static str = "/api/admin/v1/personal-sessions";
893+
894+
fn id(&self) -> Ulid {
895+
self.id
896+
}
897+
}
898+
899+
impl PersonalSession {
900+
/// Sample personal sessions for documentation/testing
901+
pub fn samples() -> [Self; 3] {
902+
[
903+
Self {
904+
id: Ulid::from_string("01FSHN9AG0AJ6AC5HQ9X6H4RP4").unwrap(),
905+
created_at: DateTime::from_timestamp(1_642_338_000, 0).unwrap(), /* 2022-01-16T14:
906+
* 40:00Z */
907+
revoked_at: None,
908+
owner_user_id: Some(Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap()),
909+
owner_client_id: None,
910+
actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(),
911+
human_name: "Alice's Development Token".to_owned(),
912+
scope: "openid urn:matrix:org.matrix.msc2967.client:api:*".to_owned(),
913+
last_active_at: Some(DateTime::from_timestamp(1_642_347_000, 0).unwrap()), /* 2022-01-16T17:10:00Z */
914+
last_active_ip: Some("192.168.1.100".parse().unwrap()),
915+
expires_at: None,
916+
access_token: None,
917+
},
918+
Self {
919+
id: Ulid::from_string("01FSHN9AG0BJ6AC5HQ9X6H4RP5").unwrap(),
920+
created_at: DateTime::from_timestamp(1_642_338_060, 0).unwrap(), /* 2022-01-16T14:
921+
* 41:00Z */
922+
revoked_at: Some(DateTime::from_timestamp(1_642_350_000, 0).unwrap()), /* 2022-01-16T18:00:00Z */
923+
owner_user_id: Some(Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap()),
924+
owner_client_id: None,
925+
actor_user_id: Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap(),
926+
human_name: "Bob's Mobile App".to_owned(),
927+
scope: "openid".to_owned(),
928+
last_active_at: Some(DateTime::from_timestamp(1_642_349_000, 0).unwrap()), /* 2022-01-16T17:43:20Z */
929+
last_active_ip: Some("10.0.0.50".parse().unwrap()),
930+
expires_at: None,
931+
access_token: None,
932+
},
933+
Self {
934+
id: Ulid::from_string("01FSHN9AG0CJ6AC5HQ9X6H4RP6").unwrap(),
935+
created_at: DateTime::from_timestamp(1_642_338_120, 0).unwrap(), /* 2022-01-16T14:
936+
* 42:00Z */
937+
revoked_at: None,
938+
owner_user_id: None,
939+
owner_client_id: Some(Ulid::from_string("01FSHN9AG0DJ6AC5HQ9X6H4RP7").unwrap()),
940+
actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(),
941+
human_name: "CI/CD Pipeline Token".to_owned(),
942+
scope: "openid urn:mas:admin".to_owned(),
943+
last_active_at: Some(DateTime::from_timestamp(1_642_348_000, 0).unwrap()), /* 2022-01-16T17:26:40Z */
944+
last_active_ip: Some("203.0.113.10".parse().unwrap()),
945+
expires_at: Some(DateTime::from_timestamp(1_642_999_000, 0).unwrap()),
946+
access_token: None,
947+
},
948+
]
949+
}
950+
951+
/// Add the actual token value (for use in creation responses)
952+
pub fn with_token(mut self, access_token: String) -> Self {
953+
self.access_token = Some(access_token);
954+
self
955+
}
956+
}

crates/handlers/src/admin/params.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,10 @@ use std::{borrow::Cow, num::NonZeroUsize};
1212
use aide::OperationIo;
1313
use axum::{
1414
Json,
15-
extract::{
16-
FromRequestParts, Path, Query,
17-
rejection::{PathRejection, QueryRejection},
18-
},
15+
extract::{FromRequestParts, Path, rejection::PathRejection},
1916
response::IntoResponse,
2017
};
18+
use axum_extra::extract::{Query, QueryRejection};
2119
use axum_macros::FromRequestParts;
2220
use hyper::StatusCode;
2321
use mas_storage::pagination::PaginationDirection;

crates/handlers/src/admin/v1/compat_sessions/list.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@
44
// Please see LICENSE files in the repository root for full details.
55

66
use aide::{OperationIo, transform::TransformOperation};
7-
use axum::{
8-
Json,
9-
extract::{Query, rejection::QueryRejection},
10-
response::IntoResponse,
11-
};
7+
use axum::{Json, response::IntoResponse};
8+
use axum_extra::extract::{Query, QueryRejection};
129
use axum_macros::FromRequestParts;
1310
use hyper::StatusCode;
1411
use mas_axum_utils::record_error;

crates/handlers/src/admin/v1/mod.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use crate::passwords::PasswordManager;
2020

2121
mod compat_sessions;
2222
mod oauth2_sessions;
23+
mod personal_sessions;
2324
mod policy_data;
2425
mod site_config;
2526
mod upstream_oauth_links;
@@ -80,6 +81,38 @@ where
8081
self::oauth2_sessions::finish_doc,
8182
),
8283
)
84+
.api_route(
85+
"/personal-sessions",
86+
get_with(
87+
self::personal_sessions::list,
88+
self::personal_sessions::list_doc,
89+
)
90+
.post_with(
91+
self::personal_sessions::add,
92+
self::personal_sessions::add_doc,
93+
),
94+
)
95+
.api_route(
96+
"/personal-sessions/{id}",
97+
get_with(
98+
self::personal_sessions::get,
99+
self::personal_sessions::get_doc,
100+
),
101+
)
102+
.api_route(
103+
"/personal-sessions/{id}/revoke",
104+
post_with(
105+
self::personal_sessions::revoke,
106+
self::personal_sessions::revoke_doc,
107+
),
108+
)
109+
.api_route(
110+
"/personal-sessions/{id}/regenerate",
111+
post_with(
112+
self::personal_sessions::regenerate,
113+
self::personal_sessions::regenerate_doc,
114+
),
115+
)
83116
.api_route(
84117
"/policy-data",
85118
post_with(self::policy_data::set, self::policy_data::set_doc),

crates/handlers/src/admin/v1/oauth2_sessions/list.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,8 @@
77
use std::str::FromStr;
88

99
use aide::{OperationIo, transform::TransformOperation};
10-
use axum::{
11-
Json,
12-
extract::{Query, rejection::QueryRejection},
13-
response::IntoResponse,
14-
};
10+
use axum::{Json, response::IntoResponse};
11+
use axum_extra::extract::{Query, QueryRejection};
1512
use axum_macros::FromRequestParts;
1613
use hyper::StatusCode;
1714
use mas_axum_utils::record_error;

0 commit comments

Comments
 (0)