Skip to content

Commit cd39513

Browse files
authored
Add admin APIs to finish individual sessions (#5091)
2 parents 8b942c9 + 7e3df55 commit cd39513

File tree

8 files changed

+989
-0
lines changed

8 files changed

+989
-0
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4+
// Please see LICENSE files in the repository root for full details.
5+
6+
use aide::{NoApi, OperationIo, transform::TransformOperation};
7+
use axum::{Json, response::IntoResponse};
8+
use hyper::StatusCode;
9+
use mas_axum_utils::record_error;
10+
use mas_data_model::BoxRng;
11+
use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob};
12+
use ulid::Ulid;
13+
14+
use crate::{
15+
admin::{
16+
call_context::CallContext,
17+
model::{CompatSession, Resource},
18+
params::UlidPathParam,
19+
response::{ErrorResponse, SingleResponse},
20+
},
21+
impl_from_error_for_route,
22+
};
23+
24+
#[derive(Debug, thiserror::Error, OperationIo)]
25+
#[aide(output_with = "Json<ErrorResponse>")]
26+
pub enum RouteError {
27+
#[error(transparent)]
28+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
29+
30+
#[error("Compatibility session with ID {0} not found")]
31+
NotFound(Ulid),
32+
33+
#[error("Compatibility session with ID {0} is already finished")]
34+
AlreadyFinished(Ulid),
35+
}
36+
37+
impl_from_error_for_route!(mas_storage::RepositoryError);
38+
39+
impl IntoResponse for RouteError {
40+
fn into_response(self) -> axum::response::Response {
41+
let error = ErrorResponse::from_error(&self);
42+
let sentry_event_id = record_error!(self, Self::Internal(_));
43+
let status = match self {
44+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
45+
Self::NotFound(_) => StatusCode::NOT_FOUND,
46+
Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST,
47+
};
48+
(status, sentry_event_id, Json(error)).into_response()
49+
}
50+
}
51+
52+
pub fn doc(operation: TransformOperation) -> TransformOperation {
53+
operation
54+
.id("finishCompatSession")
55+
.summary("Finish a compatibility session")
56+
.description(
57+
"Calling this endpoint will finish the compatibility session, preventing any further use. A job will be scheduled to sync the user's devices with the homeserver.",
58+
)
59+
.tag("compat-session")
60+
.response_with::<200, Json<SingleResponse<CompatSession>>, _>(|t| {
61+
// Get the finished session sample
62+
let [_, finished_session, _] = CompatSession::samples();
63+
let id = finished_session.id();
64+
let response = SingleResponse::new(
65+
finished_session,
66+
format!("/api/admin/v1/compat-sessions/{id}/finish"),
67+
);
68+
t.description("Compatibility session was finished").example(response)
69+
})
70+
.response_with::<400, RouteError, _>(|t| {
71+
let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil()));
72+
t.description("Session is already finished")
73+
.example(response)
74+
})
75+
.response_with::<404, RouteError, _>(|t| {
76+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
77+
t.description("Compatibility session was not found")
78+
.example(response)
79+
})
80+
}
81+
82+
#[tracing::instrument(name = "handler.admin.v1.compat_sessions.finish", skip_all)]
83+
pub async fn handler(
84+
CallContext {
85+
mut repo, clock, ..
86+
}: CallContext,
87+
NoApi(mut rng): NoApi<BoxRng>,
88+
id: UlidPathParam,
89+
) -> Result<Json<SingleResponse<CompatSession>>, RouteError> {
90+
let id = *id;
91+
let session = repo
92+
.compat_session()
93+
.lookup(id)
94+
.await?
95+
.ok_or(RouteError::NotFound(id))?;
96+
97+
// Check if the session is already finished
98+
if session.finished_at().is_some() {
99+
return Err(RouteError::AlreadyFinished(id));
100+
}
101+
102+
// Schedule a job to sync the devices of the user with the homeserver
103+
tracing::info!(user.id = %session.user_id, "Scheduling device sync job for user");
104+
repo.queue_job()
105+
.schedule_job(
106+
&mut rng,
107+
&clock,
108+
SyncDevicesJob::new_for_id(session.user_id),
109+
)
110+
.await?;
111+
112+
// Finish the session
113+
let session = repo.compat_session().finish(&clock, session).await?;
114+
115+
// Get the SSO login info for the response
116+
let sso_login = repo.compat_sso_login().find_for_session(&session).await?;
117+
118+
repo.save().await?;
119+
120+
Ok(Json(SingleResponse::new(
121+
CompatSession::from((session, sso_login)),
122+
format!("/api/admin/v1/compat-sessions/{id}/finish"),
123+
)))
124+
}
125+
126+
#[cfg(test)]
127+
mod tests {
128+
use chrono::Duration;
129+
use hyper::{Request, StatusCode};
130+
use mas_data_model::{Clock as _, Device};
131+
use sqlx::PgPool;
132+
133+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
134+
135+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
136+
async fn test_finish_session(pool: PgPool) {
137+
setup();
138+
let mut state = TestState::from_pool(pool).await.unwrap();
139+
let token = state.token_with_scope("urn:mas:admin").await;
140+
let mut rng = state.rng();
141+
142+
// Provision a user and a compat session
143+
let mut repo = state.repository().await.unwrap();
144+
let user = repo
145+
.user()
146+
.add(&mut rng, &state.clock, "alice".to_owned())
147+
.await
148+
.unwrap();
149+
let device = Device::generate(&mut rng);
150+
let session = repo
151+
.compat_session()
152+
.add(&mut rng, &state.clock, &user, device, None, false, None)
153+
.await
154+
.unwrap();
155+
repo.save().await.unwrap();
156+
157+
let request = Request::post(format!(
158+
"/api/admin/v1/compat-sessions/{}/finish",
159+
session.id
160+
))
161+
.bearer(&token)
162+
.empty();
163+
let response = state.request(request).await;
164+
response.assert_status(StatusCode::OK);
165+
let body: serde_json::Value = response.json();
166+
167+
// The finished_at timestamp should be the same as the current time
168+
assert_eq!(
169+
body["data"]["attributes"]["finished_at"],
170+
serde_json::json!(state.clock.now())
171+
);
172+
}
173+
174+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
175+
async fn test_finish_already_finished_session(pool: PgPool) {
176+
setup();
177+
let mut state = TestState::from_pool(pool).await.unwrap();
178+
let token = state.token_with_scope("urn:mas:admin").await;
179+
let mut rng = state.rng();
180+
181+
// Provision a user and a compat session
182+
let mut repo = state.repository().await.unwrap();
183+
let user = repo
184+
.user()
185+
.add(&mut rng, &state.clock, "alice".to_owned())
186+
.await
187+
.unwrap();
188+
let device = Device::generate(&mut rng);
189+
let session = repo
190+
.compat_session()
191+
.add(&mut rng, &state.clock, &user, device, None, false, None)
192+
.await
193+
.unwrap();
194+
195+
// Finish the session first
196+
let session = repo
197+
.compat_session()
198+
.finish(&state.clock, session)
199+
.await
200+
.unwrap();
201+
202+
repo.save().await.unwrap();
203+
204+
// Move the clock forward
205+
state.clock.advance(Duration::try_minutes(1).unwrap());
206+
207+
let request = Request::post(format!(
208+
"/api/admin/v1/compat-sessions/{}/finish",
209+
session.id
210+
))
211+
.bearer(&token)
212+
.empty();
213+
let response = state.request(request).await;
214+
response.assert_status(StatusCode::BAD_REQUEST);
215+
let body: serde_json::Value = response.json();
216+
assert_eq!(
217+
body["errors"][0]["title"],
218+
format!(
219+
"Compatibility session with ID {} is already finished",
220+
session.id
221+
)
222+
);
223+
}
224+
225+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
226+
async fn test_finish_unknown_session(pool: PgPool) {
227+
setup();
228+
let mut state = TestState::from_pool(pool).await.unwrap();
229+
let token = state.token_with_scope("urn:mas:admin").await;
230+
231+
let request =
232+
Request::post("/api/admin/v1/compat-sessions/01040G2081040G2081040G2081/finish")
233+
.bearer(&token)
234+
.empty();
235+
let response = state.request(request).await;
236+
response.assert_status(StatusCode::NOT_FOUND);
237+
let body: serde_json::Value = response.json();
238+
assert_eq!(
239+
body["errors"][0]["title"],
240+
"Compatibility session with ID 01040G2081040G2081040G2081 not found"
241+
);
242+
}
243+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
44
// Please see LICENSE files in the repository root for full details.
55

6+
mod finish;
67
mod get;
78
mod list;
89

910
pub use self::{
11+
finish::{doc as finish_doc, handler as finish},
1012
get::{doc as get_doc, handler as get},
1113
list::{doc as list_doc, handler as list},
1214
};

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ where
5858
"/compat-sessions/{id}",
5959
get_with(self::compat_sessions::get, self::compat_sessions::get_doc),
6060
)
61+
.api_route(
62+
"/compat-sessions/{id}/finish",
63+
post_with(
64+
self::compat_sessions::finish,
65+
self::compat_sessions::finish_doc,
66+
),
67+
)
6168
.api_route(
6269
"/oauth2-sessions",
6370
get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc),
@@ -66,6 +73,13 @@ where
6673
"/oauth2-sessions/{id}",
6774
get_with(self::oauth2_sessions::get, self::oauth2_sessions::get_doc),
6875
)
76+
.api_route(
77+
"/oauth2-sessions/{id}/finish",
78+
post_with(
79+
self::oauth2_sessions::finish,
80+
self::oauth2_sessions::finish_doc,
81+
),
82+
)
6983
.api_route(
7084
"/policy-data",
7185
post_with(self::policy_data::set, self::policy_data::set_doc),
@@ -136,6 +150,10 @@ where
136150
"/user-sessions/{id}",
137151
get_with(self::user_sessions::get, self::user_sessions::get_doc),
138152
)
153+
.api_route(
154+
"/user-sessions/{id}/finish",
155+
post_with(self::user_sessions::finish, self::user_sessions::finish_doc),
156+
)
139157
.api_route(
140158
"/user-registration-tokens",
141159
get_with(

0 commit comments

Comments
 (0)