Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/handlers/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ where
description: Some("Manage emails associated with users".to_owned()),
..Tag::default()
})
.tag(Tag {
name: "user-sessions".to_owned(),
description: Some("Manage browser sessions of users".to_owned()),
..Tag::default()
})
.security_scheme(
"oauth2",
SecurityScheme::OAuth2 {
Expand Down
84 changes: 84 additions & 0 deletions crates/handlers/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,87 @@ impl Resource for OAuth2Session {
self.id
}
}

/// The browser (cookie) session for a user
#[derive(Serialize, JsonSchema)]
pub struct UserSession {
#[serde(skip)]
id: Ulid,

/// When the object was created
created_at: DateTime<Utc>,

/// When the session was finished
finished_at: Option<DateTime<Utc>>,

/// The ID of the user who owns the session
#[schemars(with = "super::schema::Ulid")]
user_id: Ulid,

/// The user agent string of the client which started this session
user_agent: Option<String>,

/// The last time the session was active
last_active_at: Option<DateTime<Utc>>,

/// The last IP address used by the session
last_active_ip: Option<IpAddr>,
}

impl From<mas_data_model::BrowserSession> for UserSession {
fn from(value: mas_data_model::BrowserSession) -> Self {
Self {
id: value.id,
created_at: value.created_at,
finished_at: value.finished_at,
user_id: value.user.id,
user_agent: value.user_agent.map(|ua| ua.raw),
last_active_at: value.last_active_at,
last_active_ip: value.last_active_ip,
}
}
}

impl UserSession {
/// Samples of user sessions
pub fn samples() -> [Self; 3] {
[
Self {
id: Ulid::from_bytes([0x01; 16]),
created_at: DateTime::default(),
finished_at: None,
user_id: Ulid::from_bytes([0x02; 16]),
user_agent: Some("Mozilla/5.0".to_owned()),
last_active_at: Some(DateTime::default()),
last_active_ip: Some("127.0.0.1".parse().unwrap()),
},
Self {
id: Ulid::from_bytes([0x02; 16]),
created_at: DateTime::default(),
finished_at: None,
user_id: Ulid::from_bytes([0x03; 16]),
user_agent: None,
last_active_at: None,
last_active_ip: None,
},
Self {
id: Ulid::from_bytes([0x03; 16]),
created_at: DateTime::default(),
finished_at: Some(DateTime::default()),
user_id: Ulid::from_bytes([0x04; 16]),
user_agent: Some("Mozilla/5.0".to_owned()),
last_active_at: Some(DateTime::default()),
last_active_ip: Some("127.0.0.1".parse().unwrap()),
},
]
}
}

impl Resource for UserSession {
const KIND: &'static str = "user-session";
const PATH: &'static str = "/api/admin/v1/user-sessions";

fn id(&self) -> Ulid {
self.id
}
}
9 changes: 9 additions & 0 deletions crates/handlers/src/admin/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use crate::passwords::PasswordManager;
mod compat_sessions;
mod oauth2_sessions;
mod user_emails;
mod user_sessions;
mod users;

pub fn router<S>() -> ApiRouter<S>
Expand Down Expand Up @@ -86,4 +87,12 @@ where
"/user-emails/{id}",
get_with(self::user_emails::get, self::user_emails::get_doc),
)
.api_route(
"/user-sessions",
get_with(self::user_sessions::list, self::user_sessions::list_doc),
)
.api_route(
"/user-sessions/{id}",
get_with(self::user_sessions::get, self::user_sessions::get_doc),
)
}
136 changes: 136 additions & 0 deletions crates/handlers/src/admin/v1/user_sessions/get.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.

use aide::{transform::TransformOperation, OperationIo};
use axum::{response::IntoResponse, Json};
use hyper::StatusCode;
use ulid::Ulid;

use crate::{
admin::{
call_context::CallContext,
model::UserSession,
params::UlidPathParam,
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};

#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),

#[error("User session ID {0} not found")]
NotFound(Ulid),
}

impl_from_error_for_route!(mas_storage::RepositoryError);

impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
let error = ErrorResponse::from_error(&self);
let status = match self {
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound(_) => StatusCode::NOT_FOUND,
};
(status, Json(error)).into_response()
}
}

pub fn doc(operation: TransformOperation) -> TransformOperation {
operation
.id("getUserSession")
.summary("Get a user session")
.tag("user-session")
.response_with::<200, Json<SingleResponse<UserSession>>, _>(|t| {
let [sample, ..] = UserSession::samples();
let response = SingleResponse::new_canonical(sample);
t.description("User session was found").example(response)
})
.response_with::<404, RouteError, _>(|t| {
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
t.description("User session was not found")
.example(response)
})
}

#[tracing::instrument(name = "handler.admin.v1.user_sessions.get", skip_all, err)]
pub async fn handler(
CallContext { mut repo, .. }: CallContext,
id: UlidPathParam,
) -> Result<Json<SingleResponse<UserSession>>, RouteError> {
let session = repo
.browser_session()
.lookup(*id)
.await?
.ok_or(RouteError::NotFound(*id))?;

Ok(Json(SingleResponse::new_canonical(UserSession::from(
session,
))))
}

#[cfg(test)]
mod tests {
use hyper::{Request, StatusCode};
use insta::assert_json_snapshot;
use sqlx::PgPool;

use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_get(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;
let mut rng = state.rng();

// Provision a user and a user session
let mut repo = state.repository().await.unwrap();
let user = repo
.user()
.add(&mut rng, &state.clock, "alice".to_owned())
.await
.unwrap();
let session = repo
.browser_session()
.add(&mut rng, &state.clock, &user, None)
.await
.unwrap();
repo.save().await.unwrap();

let session_id = session.id;
let request = Request::get(format!("/api/admin/v1/user-sessions/{session_id}"))
.bearer(&token)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_json_snapshot!(body, @r###"
{
"data": {
"type": "user-session",
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
"attributes": {
"created_at": "2022-01-16T14:40:00Z",
"finished_at": null,
"user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
"user_agent": null,
"last_active_at": null,
"last_active_ip": null
},
"links": {
"self": "/api/admin/v1/user-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
}
},
"links": {
"self": "/api/admin/v1/user-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
}
}
"###);
}
}
Loading
Loading