Skip to content

Commit 42841ce

Browse files
committed
Admin API to list and get compatibility sessions
1 parent 9c5d2ce commit 42841ce

File tree

8 files changed

+1267
-50
lines changed

8 files changed

+1267
-50
lines changed

crates/handlers/src/admin/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ where
5656
.nest("/api/admin/v1", self::v1::router())
5757
.finish_api_with(&mut api, |t| {
5858
t.title("Matrix Authentication Service admin API")
59+
.tag(Tag {
60+
name: "compat-session".to_owned(),
61+
description: Some(
62+
"Manage compatibility sessions from legacy clients".to_owned(),
63+
),
64+
..Tag::default()
65+
})
5966
.tag(Tag {
6067
name: "oauth2-session".to_owned(),
6168
description: Some("Manage OAuth2 sessions".to_owned()),

crates/handlers/src/admin/model.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
use std::net::IpAddr;
88

99
use chrono::{DateTime, Utc};
10+
use mas_data_model::Device;
1011
use schemars::JsonSchema;
1112
use serde::Serialize;
1213
use ulid::Ulid;
14+
use url::Url;
1315

1416
/// A resource, with a type and an ID
1517
pub trait Resource {
@@ -147,6 +149,123 @@ impl UserEmail {
147149
}
148150
}
149151

152+
/// A compatibility session for legacy clients
153+
#[derive(Serialize, JsonSchema)]
154+
pub struct CompatSession {
155+
#[serde(skip)]
156+
pub id: Ulid,
157+
158+
/// The ID of the user that owns this session
159+
#[schemars(with = "super::schema::Ulid")]
160+
pub user_id: Ulid,
161+
162+
/// The Matrix device ID of this session
163+
#[schemars(with = "super::schema::Device")]
164+
pub device_id: Option<Device>,
165+
166+
/// The ID of the user session that started this session, if any
167+
#[schemars(with = "super::schema::Ulid")]
168+
pub user_session_id: Option<Ulid>,
169+
170+
/// The redirect URI used to login in the client, if it was an SSO login
171+
pub redirect_uri: Option<Url>,
172+
173+
/// The time this session was created
174+
pub created_at: DateTime<Utc>,
175+
176+
/// The user agent string that started this session, if any
177+
pub user_agent: Option<String>,
178+
179+
/// The time this session was last active
180+
pub last_active_at: Option<DateTime<Utc>>,
181+
182+
/// The last IP address recorded for this session
183+
pub last_active_ip: Option<std::net::IpAddr>,
184+
185+
/// The time this session was finished
186+
pub finished_at: Option<DateTime<Utc>>,
187+
}
188+
189+
impl
190+
From<(
191+
mas_data_model::CompatSession,
192+
Option<mas_data_model::CompatSsoLogin>,
193+
)> for CompatSession
194+
{
195+
fn from(
196+
(session, sso_login): (
197+
mas_data_model::CompatSession,
198+
Option<mas_data_model::CompatSsoLogin>,
199+
),
200+
) -> Self {
201+
let finished_at = session.finished_at();
202+
Self {
203+
id: session.id,
204+
user_id: session.user_id,
205+
device_id: session.device,
206+
user_session_id: session.user_session_id,
207+
redirect_uri: sso_login.map(|sso| sso.redirect_uri),
208+
created_at: session.created_at,
209+
user_agent: session.user_agent.map(|ua| ua.raw),
210+
last_active_at: session.last_active_at,
211+
last_active_ip: session.last_active_ip,
212+
finished_at,
213+
}
214+
}
215+
}
216+
217+
impl Resource for CompatSession {
218+
const KIND: &'static str = "compat-session";
219+
const PATH: &'static str = "/api/admin/v1/compat-sessions";
220+
221+
fn id(&self) -> Ulid {
222+
self.id
223+
}
224+
}
225+
226+
impl CompatSession {
227+
pub fn samples() -> [Self; 3] {
228+
[
229+
Self {
230+
id: Ulid::from_bytes([0x01; 16]),
231+
user_id: Ulid::from_bytes([0x01; 16]),
232+
device_id: Some("AABBCCDDEE".to_owned().try_into().unwrap()),
233+
user_session_id: Some(Ulid::from_bytes([0x11; 16])),
234+
redirect_uri: Some("https://example.com/redirect".parse().unwrap()),
235+
created_at: DateTime::default(),
236+
user_agent: Some("Mozilla/5.0".to_owned()),
237+
last_active_at: Some(DateTime::default()),
238+
last_active_ip: Some([1, 2, 3, 4].into()),
239+
finished_at: None,
240+
},
241+
Self {
242+
id: Ulid::from_bytes([0x02; 16]),
243+
user_id: Ulid::from_bytes([0x01; 16]),
244+
device_id: Some("FFGGHHIIJJ".to_owned().try_into().unwrap()),
245+
user_session_id: Some(Ulid::from_bytes([0x12; 16])),
246+
redirect_uri: None,
247+
created_at: DateTime::default(),
248+
user_agent: Some("Mozilla/5.0".to_owned()),
249+
last_active_at: Some(DateTime::default()),
250+
last_active_ip: Some([1, 2, 3, 4].into()),
251+
finished_at: Some(DateTime::default()),
252+
},
253+
Self {
254+
id: Ulid::from_bytes([0x03; 16]),
255+
user_id: Ulid::from_bytes([0x01; 16]),
256+
device_id: None,
257+
user_session_id: None,
258+
redirect_uri: None,
259+
created_at: DateTime::default(),
260+
user_agent: None,
261+
last_active_at: None,
262+
last_active_ip: None,
263+
finished_at: None,
264+
},
265+
]
266+
}
267+
}
268+
150269
/// A OAuth 2.0 session
151270
#[derive(Serialize, JsonSchema)]
152271
pub struct OAuth2Session {

crates/handlers/src/admin/schema.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,34 @@ impl JsonSchema for Ulid {
4646
.into()
4747
}
4848
}
49+
50+
/// A type to use for schema definitions of device IDs
51+
///
52+
/// Use with `#[schemars(with = "crate::admin::schema::Device")]`
53+
pub struct Device;
54+
55+
impl JsonSchema for Device {
56+
fn schema_name() -> String {
57+
"DeviceID".to_owned()
58+
}
59+
60+
fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
61+
SchemaObject {
62+
instance_type: Some(InstanceType::String.into()),
63+
64+
metadata: Some(Box::new(Metadata {
65+
title: Some("Device ID".into()),
66+
examples: vec!["AABBCCDDEE".into(), "FFGGHHIIJJ".into()],
67+
..Metadata::default()
68+
})),
69+
70+
string: Some(Box::new(StringValidation {
71+
pattern: Some(r"^[A-Za-z0-9._~!$&'()*+,;=:&/-]+$".into()),
72+
..StringValidation::default()
73+
})),
74+
75+
..SchemaObject::default()
76+
}
77+
.into()
78+
}
79+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
use aide::{transform::TransformOperation, OperationIo};
7+
use axum::{response::IntoResponse, Json};
8+
use hyper::StatusCode;
9+
use ulid::Ulid;
10+
11+
use crate::{
12+
admin::{
13+
call_context::CallContext,
14+
model::CompatSession,
15+
params::UlidPathParam,
16+
response::{ErrorResponse, SingleResponse},
17+
},
18+
impl_from_error_for_route,
19+
};
20+
21+
#[derive(Debug, thiserror::Error, OperationIo)]
22+
#[aide(output_with = "Json<ErrorResponse>")]
23+
pub enum RouteError {
24+
#[error(transparent)]
25+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
26+
27+
#[error("Compatibility session ID {0} not found")]
28+
NotFound(Ulid),
29+
}
30+
31+
impl_from_error_for_route!(mas_storage::RepositoryError);
32+
33+
impl IntoResponse for RouteError {
34+
fn into_response(self) -> axum::response::Response {
35+
let error = ErrorResponse::from_error(&self);
36+
let status = match self {
37+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
38+
Self::NotFound(_) => StatusCode::NOT_FOUND,
39+
};
40+
(status, Json(error)).into_response()
41+
}
42+
}
43+
44+
pub fn doc(operation: TransformOperation) -> TransformOperation {
45+
operation
46+
.id("getCompatSession")
47+
.summary("Get a compatibility session")
48+
.tag("compat-session")
49+
.response_with::<200, Json<SingleResponse<CompatSession>>, _>(|t| {
50+
let [sample, ..] = CompatSession::samples();
51+
let response = SingleResponse::new_canonical(sample);
52+
t.description("Compatibility session was found")
53+
.example(response)
54+
})
55+
.response_with::<404, RouteError, _>(|t| {
56+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
57+
t.description("Compatibility session was not found")
58+
.example(response)
59+
})
60+
}
61+
62+
#[tracing::instrument(name = "handler.admin.v1.compat_sessions.get", skip_all, err)]
63+
pub async fn handler(
64+
CallContext { mut repo, .. }: CallContext,
65+
id: UlidPathParam,
66+
) -> Result<Json<SingleResponse<CompatSession>>, RouteError> {
67+
let session = repo
68+
.compat_session()
69+
.lookup(*id)
70+
.await?
71+
.ok_or(RouteError::NotFound(*id))?;
72+
73+
let sso_login = repo.compat_sso_login().find_for_session(&session).await?;
74+
75+
Ok(Json(SingleResponse::new_canonical(CompatSession::from((
76+
session, sso_login,
77+
)))))
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use hyper::{Request, StatusCode};
83+
use insta::assert_json_snapshot;
84+
use mas_data_model::Device;
85+
use sqlx::PgPool;
86+
use ulid::Ulid;
87+
88+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
89+
90+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
91+
async fn test_get(pool: PgPool) {
92+
setup();
93+
let mut state = TestState::from_pool(pool).await.unwrap();
94+
let token = state.token_with_scope("urn:mas:admin").await;
95+
let mut rng = state.rng();
96+
97+
// Provision a user and a compat session
98+
let mut repo = state.repository().await.unwrap();
99+
let user = repo
100+
.user()
101+
.add(&mut rng, &state.clock, "alice".to_owned())
102+
.await
103+
.unwrap();
104+
let device = Device::generate(&mut rng);
105+
let session = repo
106+
.compat_session()
107+
.add(&mut rng, &state.clock, &user, device, None, false)
108+
.await
109+
.unwrap();
110+
repo.save().await.unwrap();
111+
112+
let session_id = session.id;
113+
let request = Request::get(format!("/api/admin/v1/compat-sessions/{session_id}"))
114+
.bearer(&token)
115+
.empty();
116+
let response = state.request(request).await;
117+
response.assert_status(StatusCode::OK);
118+
let body: serde_json::Value = response.json();
119+
assert_json_snapshot!(body, @r###"
120+
{
121+
"data": {
122+
"type": "compat-session",
123+
"id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
124+
"attributes": {
125+
"user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
126+
"device_id": "TpLoieH5Ie",
127+
"user_session_id": null,
128+
"redirect_uri": null,
129+
"created_at": "2022-01-16T14:40:00Z",
130+
"user_agent": null,
131+
"last_active_at": null,
132+
"last_active_ip": null,
133+
"finished_at": null
134+
},
135+
"links": {
136+
"self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07"
137+
}
138+
},
139+
"links": {
140+
"self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07"
141+
}
142+
}
143+
"###);
144+
}
145+
146+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
147+
async fn test_not_found(pool: PgPool) {
148+
setup();
149+
let mut state = TestState::from_pool(pool).await.unwrap();
150+
let token = state.token_with_scope("urn:mas:admin").await;
151+
152+
let session_id = Ulid::nil();
153+
let request = Request::get(format!("/api/admin/v1/compat-sessions/{session_id}"))
154+
.bearer(&token)
155+
.empty();
156+
let response = state.request(request).await;
157+
response.assert_status(StatusCode::NOT_FOUND);
158+
}
159+
}

0 commit comments

Comments
 (0)