Skip to content

Commit 3792cd4

Browse files
authored
Admin API to list and get user sessions (#4004)
Similar to #4002, this adds an admin API to list and get user (browser cookies) sessions
2 parents b0bc692 + 2a9fb26 commit 3792cd4

File tree

7 files changed

+1024
-0
lines changed

7 files changed

+1024
-0
lines changed

crates/handlers/src/admin/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ where
8282
description: Some("Manage emails associated with users".to_owned()),
8383
..Tag::default()
8484
})
85+
.tag(Tag {
86+
name: "user-sessions".to_owned(),
87+
description: Some("Manage browser sessions of users".to_owned()),
88+
..Tag::default()
89+
})
8590
.security_scheme(
8691
"oauth2",
8792
SecurityScheme::OAuth2 {

crates/handlers/src/admin/model.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,87 @@ impl Resource for OAuth2Session {
372372
self.id
373373
}
374374
}
375+
376+
/// The browser (cookie) session for a user
377+
#[derive(Serialize, JsonSchema)]
378+
pub struct UserSession {
379+
#[serde(skip)]
380+
id: Ulid,
381+
382+
/// When the object was created
383+
created_at: DateTime<Utc>,
384+
385+
/// When the session was finished
386+
finished_at: Option<DateTime<Utc>>,
387+
388+
/// The ID of the user who owns the session
389+
#[schemars(with = "super::schema::Ulid")]
390+
user_id: Ulid,
391+
392+
/// The user agent string of the client which started this session
393+
user_agent: Option<String>,
394+
395+
/// The last time the session was active
396+
last_active_at: Option<DateTime<Utc>>,
397+
398+
/// The last IP address used by the session
399+
last_active_ip: Option<IpAddr>,
400+
}
401+
402+
impl From<mas_data_model::BrowserSession> for UserSession {
403+
fn from(value: mas_data_model::BrowserSession) -> Self {
404+
Self {
405+
id: value.id,
406+
created_at: value.created_at,
407+
finished_at: value.finished_at,
408+
user_id: value.user.id,
409+
user_agent: value.user_agent.map(|ua| ua.raw),
410+
last_active_at: value.last_active_at,
411+
last_active_ip: value.last_active_ip,
412+
}
413+
}
414+
}
415+
416+
impl UserSession {
417+
/// Samples of user sessions
418+
pub fn samples() -> [Self; 3] {
419+
[
420+
Self {
421+
id: Ulid::from_bytes([0x01; 16]),
422+
created_at: DateTime::default(),
423+
finished_at: None,
424+
user_id: Ulid::from_bytes([0x02; 16]),
425+
user_agent: Some("Mozilla/5.0".to_owned()),
426+
last_active_at: Some(DateTime::default()),
427+
last_active_ip: Some("127.0.0.1".parse().unwrap()),
428+
},
429+
Self {
430+
id: Ulid::from_bytes([0x02; 16]),
431+
created_at: DateTime::default(),
432+
finished_at: None,
433+
user_id: Ulid::from_bytes([0x03; 16]),
434+
user_agent: None,
435+
last_active_at: None,
436+
last_active_ip: None,
437+
},
438+
Self {
439+
id: Ulid::from_bytes([0x03; 16]),
440+
created_at: DateTime::default(),
441+
finished_at: Some(DateTime::default()),
442+
user_id: Ulid::from_bytes([0x04; 16]),
443+
user_agent: Some("Mozilla/5.0".to_owned()),
444+
last_active_at: Some(DateTime::default()),
445+
last_active_ip: Some("127.0.0.1".parse().unwrap()),
446+
},
447+
]
448+
}
449+
}
450+
451+
impl Resource for UserSession {
452+
const KIND: &'static str = "user-session";
453+
const PATH: &'static str = "/api/admin/v1/user-sessions";
454+
455+
fn id(&self) -> Ulid {
456+
self.id
457+
}
458+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::passwords::PasswordManager;
1818
mod compat_sessions;
1919
mod oauth2_sessions;
2020
mod user_emails;
21+
mod user_sessions;
2122
mod users;
2223

2324
pub fn router<S>() -> ApiRouter<S>
@@ -86,4 +87,12 @@ where
8687
"/user-emails/{id}",
8788
get_with(self::user_emails::get, self::user_emails::get_doc),
8889
)
90+
.api_route(
91+
"/user-sessions",
92+
get_with(self::user_sessions::list, self::user_sessions::list_doc),
93+
)
94+
.api_route(
95+
"/user-sessions/{id}",
96+
get_with(self::user_sessions::get, self::user_sessions::get_doc),
97+
)
8998
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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::UserSession,
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("User 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("getUserSession")
47+
.summary("Get a user session")
48+
.tag("user-session")
49+
.response_with::<200, Json<SingleResponse<UserSession>>, _>(|t| {
50+
let [sample, ..] = UserSession::samples();
51+
let response = SingleResponse::new_canonical(sample);
52+
t.description("User session was found").example(response)
53+
})
54+
.response_with::<404, RouteError, _>(|t| {
55+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
56+
t.description("User session was not found")
57+
.example(response)
58+
})
59+
}
60+
61+
#[tracing::instrument(name = "handler.admin.v1.user_sessions.get", skip_all, err)]
62+
pub async fn handler(
63+
CallContext { mut repo, .. }: CallContext,
64+
id: UlidPathParam,
65+
) -> Result<Json<SingleResponse<UserSession>>, RouteError> {
66+
let session = repo
67+
.browser_session()
68+
.lookup(*id)
69+
.await?
70+
.ok_or(RouteError::NotFound(*id))?;
71+
72+
Ok(Json(SingleResponse::new_canonical(UserSession::from(
73+
session,
74+
))))
75+
}
76+
77+
#[cfg(test)]
78+
mod tests {
79+
use hyper::{Request, StatusCode};
80+
use insta::assert_json_snapshot;
81+
use sqlx::PgPool;
82+
83+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
84+
85+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
86+
async fn test_get(pool: PgPool) {
87+
setup();
88+
let mut state = TestState::from_pool(pool).await.unwrap();
89+
let token = state.token_with_scope("urn:mas:admin").await;
90+
let mut rng = state.rng();
91+
92+
// Provision a user and a user session
93+
let mut repo = state.repository().await.unwrap();
94+
let user = repo
95+
.user()
96+
.add(&mut rng, &state.clock, "alice".to_owned())
97+
.await
98+
.unwrap();
99+
let session = repo
100+
.browser_session()
101+
.add(&mut rng, &state.clock, &user, None)
102+
.await
103+
.unwrap();
104+
repo.save().await.unwrap();
105+
106+
let session_id = session.id;
107+
let request = Request::get(format!("/api/admin/v1/user-sessions/{session_id}"))
108+
.bearer(&token)
109+
.empty();
110+
let response = state.request(request).await;
111+
response.assert_status(StatusCode::OK);
112+
let body: serde_json::Value = response.json();
113+
assert_json_snapshot!(body, @r###"
114+
{
115+
"data": {
116+
"type": "user-session",
117+
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
118+
"attributes": {
119+
"created_at": "2022-01-16T14:40:00Z",
120+
"finished_at": null,
121+
"user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
122+
"user_agent": null,
123+
"last_active_at": null,
124+
"last_active_ip": null
125+
},
126+
"links": {
127+
"self": "/api/admin/v1/user-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
128+
}
129+
},
130+
"links": {
131+
"self": "/api/admin/v1/user-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
132+
}
133+
}
134+
"###);
135+
}
136+
}

0 commit comments

Comments
 (0)