Record and log the requester on the request log line (#5735)

This commit is contained in:
Quentin Gliech authored and GitHub committed 2026-06-18 14:17:36 +02:00
commit 99e7b5834e
25 files changed
+467 -15

No files matched your search

Generated
+2
View File
@@ -3102,6 +3102,7 @@ dependencies = [
"headers",
"http",
"icu_locid",
"mas-context",
"mas-data-model",
"mas-http",
"mas-iana",
@@ -3244,6 +3245,7 @@ dependencies = [
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"ulid",
]
[[package]]
+1
View File
@@ -39,6 +39,7 @@ url.workspace = true
ulid.workspace = true
oauth2-types.workspace = true
mas-context.workspace = true
mas-data-model.workspace = true
mas-http.workspace = true
mas-iana.workspace = true
+3
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
@@ -14,6 +15,7 @@ pub mod error_wrapper;
pub mod fancy_error;
pub mod jwt;
pub mod language_detection;
pub mod log_context;
pub mod sentry;
pub mod session;
pub mod user_authorization;
@@ -23,5 +25,6 @@ pub use axum;
pub use self::{
error_wrapper::ErrorWrapper,
fancy_error::{GenericError, InternalError},
log_context::RecordAsRequester,
session::{SessionInfo, SessionInfoExt},
};
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Ergonomic glue between domain types and the [`LogContext`] requester slot.
//!
//! Auth-resolution sites can call `value.maybe_record_as_requester()` on a
//! [`User`], [`BrowserSession`], [`Session`], [`CompatSession`] or [`Client`]
//! instead of reaching for [`LogContext::maybe_set_requester`] + [`Requester`]
//! directly. Each call site should record the entity that *authenticated the
//! request* exactly once; [`LogContext`] is first-writer-wins, so an accidental
//! second call is harmless.
use mas_context::{LogContext, Requester};
use mas_data_model::{BrowserSession, Client, CompatSession, Session, User};
pub trait RecordAsRequester {
/// Record `self` as the [`Requester`] on the current [`LogContext`], if
/// any.
///
/// First writer wins — calling on a request that already has a requester
/// recorded is a no-op.
fn maybe_record_as_requester(&self);
}
impl RecordAsRequester for User {
fn maybe_record_as_requester(&self) {
LogContext::maybe_set_requester(Requester::user(self.id, self.username.clone()));
}
}
impl RecordAsRequester for BrowserSession {
fn maybe_record_as_requester(&self) {
self.user.maybe_record_as_requester();
}
}
impl RecordAsRequester for Session {
/// Records the `OAuth2` session's user (without a username) when it has
/// one, otherwise the client acting on its own behalf
/// (client-credentials).
fn maybe_record_as_requester(&self) {
if let Some(user_id) = self.user_id {
LogContext::maybe_set_requester(Requester::user_id_only(user_id));
} else {
LogContext::maybe_set_requester(Requester::oauth2_client(self.client_id));
}
}
}
impl RecordAsRequester for CompatSession {
fn maybe_record_as_requester(&self) {
LogContext::maybe_set_requester(Requester::user_id_only(self.user_id));
}
}
impl RecordAsRequester for Client {
fn maybe_record_as_requester(&self) {
LogContext::maybe_set_requester(Requester::oauth2_client(self.id));
}
}
+6 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
@@ -9,7 +10,7 @@ use mas_storage::RepositoryAccess;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::cookies::CookieJar;
use crate::{cookies::CookieJar, log_context::RecordAsRequester};
/// An encrypted cookie to save the session ID
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
@@ -53,6 +54,10 @@ impl SessionInfo {
// Ensure that the session is still active
.filter(BrowserSession::active);
if let Some(session) = &maybe_session {
session.maybe_record_as_requester();
}
Ok(maybe_session)
}
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
@@ -24,6 +25,8 @@ use mas_storage::{
use serde::{Deserialize, de::DeserializeOwned};
use thiserror::Error;
use crate::log_context::RecordAsRequester;
#[derive(Debug, Deserialize)]
struct AuthorizedForm<F> {
#[serde(default)]
@@ -62,6 +65,8 @@ impl AccessToken {
.await?
.ok_or(AuthorizationVerificationError::InvalidToken)?;
session.maybe_record_as_requester();
Ok((token, session))
}
}
+9 -1
View File
@@ -217,7 +217,12 @@ async fn log_response_middleware(
.and_then(|operation| operation.operation_name.as_deref())
.map(tracing::field::display);
let Some(stats) = LogContext::maybe_with(LogContext::stats) else {
let Some((stats, requester)) = LogContext::maybe_with(|ctx| {
(
ctx.stats(),
ctx.requester().cloned().map(tracing::field::display),
)
}) else {
tracing::error!("Missing log context for request, this is a bug!");
return response;
};
@@ -227,6 +232,7 @@ async fn log_response_middleware(
100..=399 => tracing::info!(
name: "http.server.response",
{
requester = requester,
client.address = client_ip,
graphql.operation.type = graphql_operation_type,
graphql.operation.name = graphql_operation_name,
@@ -236,6 +242,7 @@ async fn log_response_middleware(
400..=499 => tracing::warn!(
name: "http.server.response",
{
requester = requester,
client.address = client_ip,
graphql.operation.type = graphql_operation_type,
graphql.operation.name = graphql_operation_name,
@@ -245,6 +252,7 @@ async fn log_response_middleware(
500..=599 => tracing::error!(
name: "http.server.response",
{
requester = requester,
client.address = client_ip,
graphql.operation.type = graphql_operation_type,
graphql.operation.name = graphql_operation_name,
+1
View File
@@ -27,3 +27,4 @@ tracing.workspace = true
tracing-subscriber.workspace = true
tracing-opentelemetry.workspace = true
opentelemetry.workspace = true
ulid.workspace = true
+88 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -11,7 +12,7 @@ mod service;
use std::{
borrow::Cow,
sync::{
Arc,
Arc, OnceLock,
atomic::{AtomicU64, Ordering},
},
time::Duration,
@@ -19,6 +20,7 @@ use std::{
use quanta::Instant;
use tokio::task_local;
use ulid::Ulid;
pub use self::{
fmt::EventFormatter,
@@ -27,6 +29,68 @@ pub use self::{
service::LogContextService,
};
/// An identified principal making a request, recorded on the [`LogContext`].
///
/// This is the entity that *authenticated the request* (the caller), not the
/// subject an operation might be about — e.g. on the introspection endpoint
/// it is the homeserver or client doing the introspecting, not the user whose
/// token is being introspected.
#[derive(Clone, Debug)]
pub enum Requester {
/// A user, identified by their Ulid and (when known) their username.
User {
id: Ulid,
/// Sometimes only the `user_id` is known at auth-resolution time (e.g.
/// `OAuth2`/compat bearer flows that don't load the `User` row).
username: Option<String>,
},
/// An `OAuth2` client acting on its own behalf (e.g. at the token endpoint,
/// or a client-credentials session), identified by its internal Ulid.
OAuth2Client { id: Ulid },
/// The homeserver, e.g. calling the introspection endpoint with its shared
/// secret.
Homeserver,
}
impl Requester {
/// Build a `User` requester with both an id and a known username.
#[must_use]
pub fn user(id: Ulid, username: impl Into<String>) -> Self {
Self::User {
id,
username: Some(username.into()),
}
}
/// Build a `User` requester from a `user_id` alone (username unknown).
#[must_use]
pub fn user_id_only(id: Ulid) -> Self {
Self::User { id, username: None }
}
/// Build an `OAuth2Client` requester from a client's internal Ulid.
#[must_use]
pub fn oauth2_client(id: Ulid) -> Self {
Self::OAuth2Client { id }
}
}
impl std::fmt::Display for Requester {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User {
id,
username: Some(name),
} => write!(f, "user:{id}({name})"),
Self::User { id, username: None } => write!(f, "user:{id}"),
Self::OAuth2Client { id } => write!(f, "oauth2-client:{id}"),
Self::Homeserver => write!(f, "homeserver"),
}
}
}
/// A counter which increments each time we create a new log context
/// It will wrap around if we create more than [`u64::MAX`] contexts
static LOG_CONTEXT_INDEX: AtomicU64 = AtomicU64::new(0);
@@ -57,6 +121,10 @@ struct LogContextInner {
/// An approximation of the total CPU time spent in the context, in
/// nanoseconds
cpu_time: AtomicU64,
/// The identified principal making the request, if it has been resolved.
/// Set once per context, first writer wins.
requester: OnceLock<Requester>,
}
impl LogContext {
@@ -69,6 +137,7 @@ impl LogContext {
start: Instant::now(),
polls: AtomicU64::new(0),
cpu_time: AtomicU64::new(0),
requester: OnceLock::new(),
};
Self {
@@ -101,6 +170,24 @@ impl LogContext {
result
}
/// Associate a [`Requester`] with this log context. Silently does nothing
/// if a requester has already been recorded (first writer wins).
pub fn set_requester(&self, requester: Requester) {
let _ = self.inner.requester.set(requester);
}
/// Get the [`Requester`] associated with this log context, if one was set.
#[must_use]
pub fn requester(&self) -> Option<&Requester> {
self.inner.requester.get()
}
/// Convenience: set the [`Requester`] on the current task-local
/// `LogContext`, if one exists. No-op outside of a `LogContext` scope.
pub fn maybe_set_requester(requester: Requester) {
Self::maybe_with(|ctx| ctx.set_requester(requester));
}
/// Create a snapshot of the log context statistics
#[must_use]
pub fn stats(&self) -> LogContextStats {
+9 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
@@ -15,7 +16,7 @@ use axum::{
use axum_extra::TypedHeader;
use headers::{Authorization, authorization::Bearer};
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_axum_utils::{RecordAsRequester, record_error};
use mas_data_model::{
BoxClock, Session, TokenFormatError, TokenType, User,
personal::session::{PersonalSession, PersonalSessionOwner},
@@ -273,10 +274,17 @@ where
}
}
user.maybe_record_as_requester();
Some(user)
} else {
// Double check we're not using a PersonalSession
assert!(matches!(session, CallerSession::OAuth2Session(_)));
// There is no user on this session (client-credentials): attribute
// the request to the client instead.
if let CallerSession::OAuth2Session(session) = &session {
session.maybe_record_as_requester();
}
None
};
+64 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
@@ -13,7 +14,7 @@ use axum::{Json, extract::State, response::IntoResponse};
use axum_extra::typed_header::TypedHeader;
use chrono::Duration;
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_axum_utils::{RecordAsRequester, record_error};
use mas_data_model::{
BoxClock, BoxRng, Clock, CompatSession, CompatSsoLoginState, Device, SessionLimitConfig,
SiteConfig, TokenType, User,
@@ -405,6 +406,8 @@ pub(crate) async fn post(
.await?;
}
user.maybe_record_as_requester();
let user_id = homeserver.mxid(&user.username);
// If the client asked for a refreshable token, make it expire
@@ -1202,6 +1205,66 @@ mod tests {
user
}
/// A successful compat password login records the acting user, with their
/// username, as the requester on the log context.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_login_records_requester(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let user = user_with_password(&state, "alice", "password", false).await;
let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({
"type": "m.login.password",
"identifier": { "type": "m.id.user", "user": "alice" },
"password": "password",
}));
let (response, requester) = state.request_capturing_requester(request).await;
response.assert_status(StatusCode::OK);
assert_eq!(requester, Some(format!("user:{}(alice)", user.id)));
}
/// A compat token refresh records the acting user too, but without the
/// username (it isn't loaded on that path) — exercising the id-only form.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_refresh_records_requester_id_only(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let user = user_with_password(&state, "alice", "password", false).await;
// Login first, asking for a refresh token.
let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({
"type": "m.login.password",
"identifier": { "type": "m.id.user", "user": "alice" },
"password": "password",
"refresh_token": true,
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
let refresh_token = body["refresh_token"].as_str().unwrap().to_owned();
let request = Request::post("/_matrix/client/v3/refresh")
.json(serde_json::json!({ "refresh_token": refresh_token }));
let (response, requester) = state.request_capturing_requester(request).await;
response.assert_status(StatusCode::OK);
assert_eq!(requester, Some(format!("user:{}", user.id)));
}
/// A request that doesn't authenticate records no requester.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_unauthenticated_request_records_no_requester(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let request = Request::get("/_matrix/client/v3/login").empty();
let (response, requester) = state.request_capturing_requester(request).await;
response.assert_status(StatusCode::OK);
assert_eq!(requester, None);
}
/// Test that the client IP injected on the request (as the IP-detection
/// middleware would in production) is recorded against the session by the
/// activity tracker.
+4 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
@@ -10,7 +11,7 @@ use axum::{Json, response::IntoResponse};
use axum_extra::typed_header::TypedHeader;
use headers::{Authorization, authorization::Bearer};
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_axum_utils::{RecordAsRequester, record_error};
use mas_data_model::{BoxClock, BoxRng, Clock, TokenType};
use mas_storage::{
BoxRepository, RepositoryAccess,
@@ -117,6 +118,8 @@ pub(crate) async fn post(
// XXX: this is probably not the right error
.ok_or(RouteError::InvalidAuthorization)?;
user.maybe_record_as_requester();
// This will make the access token invalid
repo.compat_session().finish(&clock, session).await?;
+4 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -9,7 +10,7 @@ use axum::{Json, response::IntoResponse};
use axum_extra::typed_header::TypedHeader;
use headers::{Authorization, authorization::Bearer};
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_axum_utils::{RecordAsRequester, record_error};
use mas_data_model::{BoxClock, BoxRng, Clock, TokenType};
use mas_storage::{
BoxRepository, RepositoryAccess,
@@ -177,6 +178,8 @@ pub(crate) async fn post(
return Err(RouteError::InvalidUser(session.user_id));
}
user.maybe_record_as_requester();
if !input.only_compat_is_fine {
return Err(RouteError::NotSupported);
}
+4 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
@@ -7,7 +8,7 @@
use axum::{Json, extract::State, response::IntoResponse};
use chrono::Duration;
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_axum_utils::{RecordAsRequester, record_error};
use mas_data_model::{BoxClock, BoxRng, Clock, SiteConfig, TokenFormatError, TokenType};
use mas_storage::{
BoxRepository,
@@ -120,6 +121,8 @@ pub(crate) async fn post(
return Err(RouteError::InvalidSession(refresh_token.session_id));
}
session.maybe_record_as_requester();
activity_tracker
.record_compat_session(&clock, &session)
.await;
+6 -1
View File
@@ -28,7 +28,8 @@ use futures_util::TryStreamExt;
use headers::{Authorization, ContentType, HeaderValue, authorization::Bearer};
use hyper::header::CACHE_CONTROL;
use mas_axum_utils::{
InternalError, SessionInfo, SessionInfoExt, cookies::CookieJar, sentry::SentryEventID,
InternalError, RecordAsRequester, SessionInfo, SessionInfoExt, cookies::CookieJar,
sentry::SentryEventID,
};
use mas_data_model::{
BoxClock, BoxRng, BrowserSession, Clock, Session, SiteConfig, SystemClock, User,
@@ -356,6 +357,10 @@ async fn get_requester(
return Err(RouteError::MissingScope);
}
if let Some(user) = &user {
user.maybe_record_as_requester();
}
RequestingEntity::OAuth2Session(Box::new((session, user)))
} else {
let maybe_session = session_info.load_active_session(&mut repo).await?;
@@ -10,6 +10,7 @@ use chrono::Duration;
use headers::{CacheControl, Pragma};
use hyper::StatusCode;
use mas_axum_utils::{
RecordAsRequester,
client_authorization::{ClientAuthorization, CredentialsVerificationError},
record_error,
};
@@ -141,6 +142,8 @@ pub(crate) async fn post(
}
})?;
client.maybe_record_as_requester();
if !client.grant_types.contains(&GrantType::DeviceCode) {
return Err(RouteError::ClientNotAllowed(client.id));
}
+120
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
@@ -12,9 +13,11 @@ use std::{
use axum::{Json, extract::State, http::HeaderValue, response::IntoResponse};
use hyper::{HeaderMap, StatusCode};
use mas_axum_utils::{
RecordAsRequester,
client_authorization::{ClientAuthorization, CredentialsVerificationError},
record_error,
};
use mas_context::{LogContext, Requester};
use mas_data_model::{
BoxClock, Clock, Device, TokenFormatError, TokenType, personal::session::PersonalSessionOwner,
};
@@ -278,6 +281,10 @@ pub(crate) async fn post(
{
return Err(RouteError::InvalidBearerToken);
}
// The caller is the homeserver itself — not the user whose token is
// being introspected below.
LogContext::maybe_set_requester(Requester::Homeserver);
} else {
// Otherwise, it presented regular client credentials, so we verify them
let client = credentials
@@ -296,6 +303,10 @@ pub(crate) async fn post(
credentials
.verify(&http_client, &encrypter, method, &client)
.await?;
// The introspecting client is the caller — not the introspected token's
// subject.
client.maybe_record_as_requester();
}
let Some(form) = form else {
@@ -764,6 +775,115 @@ mod tests {
test_utils::{RequestBuilderExt, ResponseExt, TestState, setup},
};
/// Introspection records the *caller* (the introspecting client, or the
/// homeserver) as the requester — never the subject of the introspected
/// token.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_introspection_records_caller_as_requester(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// A confidential client allowed to introspect.
let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({
"client_uri": "https://introspecting.com/",
"grant_types": [],
"token_endpoint_auth_method": "client_secret_basic",
}));
let response = state.request(request).await;
response.assert_status(StatusCode::CREATED);
let client: ClientRegistrationResponse = response.json();
let introspecting_client_id = client.client_id;
let introspecting_client_secret = client.client_secret.unwrap();
// A separate client + user + oauth2 session, to mint a token to introspect.
let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({
"client_uri": "https://client.com/",
"redirect_uris": ["https://client.com/"],
"response_types": ["code"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none",
}));
let response = state.request(request).await;
response.assert_status(StatusCode::CREATED);
let ClientRegistrationResponse { client_id, .. } = response.json();
let mut repo = state.repository().await.unwrap();
let user = repo
.user()
.add(&mut state.rng(), &state.clock, "alice".to_owned())
.await
.unwrap();
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
let client = repo
.oauth2_client()
.find_by_client_id(&client_id)
.await
.unwrap()
.unwrap();
let browser_session = repo
.browser_session()
.add(&mut state.rng(), &state.clock, &user, None)
.await
.unwrap();
let session = repo
.oauth2_session()
.add_from_browser_session(
&mut state.rng(),
&state.clock,
&client,
&browser_session,
Scope::from_iter([OPENID]),
)
.await
.unwrap();
let (AccessToken { access_token, .. }, _) = generate_token_pair(
&mut state.rng(),
&state.clock,
&mut repo,
&session,
Duration::microseconds(5 * 60 * 1000 * 1000),
)
.await
.unwrap();
// The internal id of the introspecting client, for the assertion below.
let introspecting_client = repo
.oauth2_client()
.find_by_client_id(&introspecting_client_id)
.await
.unwrap()
.unwrap();
repo.save().await.unwrap();
// (1) Introspecting with client credentials: the introspecting client is
// the requester — not alice, who owns the token.
let request = Request::post(OAuth2Introspection::PATH)
.basic_auth(&introspecting_client_id, &introspecting_client_secret)
.form(json!({ "token": access_token }));
let (response, requester) = state.request_capturing_requester(request).await;
response.assert_status(StatusCode::OK);
assert_eq!(
requester,
Some(format!("oauth2-client:{}", introspecting_client.id))
);
// (2) Introspecting with the homeserver's bearer token: the homeserver is
// the requester.
let request = Request::post(OAuth2Introspection::PATH)
.header(
"authorization",
format!("Bearer {}", MockHomeserverConnection::VALID_BEARER_TOKEN),
)
.form(json!({ "token": access_token }));
let (response, requester) = state.request_capturing_requester(request).await;
response.assert_status(StatusCode::OK);
assert_eq!(requester, Some("homeserver".to_owned()));
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_introspect_oauth_tokens(pool: PgPool) {
setup();
+3
View File
@@ -7,6 +7,7 @@
use axum::{Json, extract::State, response::IntoResponse};
use hyper::StatusCode;
use mas_axum_utils::{
RecordAsRequester,
client_authorization::{ClientAuthorization, CredentialsVerificationError},
record_error,
};
@@ -157,6 +158,8 @@ pub(crate) async fn post(
}
})?;
client.maybe_record_as_requester();
let Some(form) = client_authorization.form else {
return Err(RouteError::BadRequest);
};
+5
View File
@@ -13,6 +13,7 @@ use chrono::Duration;
use headers::{CacheControl, HeaderMap, HeaderMapExt, Pragma};
use hyper::StatusCode;
use mas_axum_utils::{
RecordAsRequester,
client_authorization::{ClientAuthorization, CredentialsVerificationError},
record_error,
};
@@ -319,6 +320,10 @@ pub(crate) async fn post(
}
})?;
// The authenticated client is the entity making this request, regardless of
// the grant type or whether a user session is later minted.
client.maybe_record_as_requester();
let form = client_authorization.form.ok_or(RouteError::BadRequest)?;
let grant_type = form.grant_type();
+6 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -7,7 +8,7 @@
//! locked or deactivated
use axum::response::{Html, IntoResponse as _, Response};
use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, csrf::CsrfExt};
use mas_axum_utils::{RecordAsRequester, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt};
use mas_data_model::{BrowserSession, Clock, User};
use mas_i18n::DataLocale;
use mas_policy::model::SessionCounts;
@@ -65,6 +66,10 @@ pub async fn load_session_or_fallback(
});
};
// Record the user as the requester even when we return a fallback page
// below — the request is still attributable to that user.
session.maybe_record_as_requester();
if session.user.deactivated_at.is_some() {
// The account is deactivated, show the 'account deactivated' fallback
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng);
+39 -1
View File
@@ -30,6 +30,7 @@ use mas_axum_utils::{
cookies::{CookieJar, CookieManager},
};
use mas_config::RateLimitingConfig;
use mas_context::LogContext;
use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, clock::MockClock};
use mas_email::{MailTransport, Mailer};
use mas_i18n::Translator;
@@ -331,7 +332,10 @@ impl TestState {
state
}
pub async fn request<B>(&self, request: Request<B>) -> Response<String>
/// Dispatch a request through the router, without setting up a log context.
/// Use [`TestState::request`] instead — it mirrors production by running
/// inside a [`LogContext`].
async fn dispatch<B>(&self, request: Request<B>) -> Response<String>
where
B: HttpBody<Data = Bytes> + Send + 'static,
<B as HttpBody>::Error: std::error::Error + Send + Sync,
@@ -366,6 +370,40 @@ impl TestState {
Response::from_parts(parts, body)
}
pub async fn request<B>(&self, request: Request<B>) -> Response<String>
where
B: HttpBody<Data = Bytes> + Send + 'static,
<B as HttpBody>::Error: std::error::Error + Send + Sync,
B::Error: std::error::Error + Send + Sync,
B::Data: Send,
{
// Mirror production: every request is served inside a `LogContext`
// (set up by the `LogContextLayer` in the real server).
LogContext::new("test-request")
.run(|| self.dispatch(request))
.await
}
/// Like [`TestState::request`], but additionally returns the requester that
/// the handlers recorded on the request's [`LogContext`] (formatted via its
/// `Display`), so tests can assert how a request was attributed. `None`
/// means no requester was recorded.
pub async fn request_capturing_requester<B>(
&self,
request: Request<B>,
) -> (Response<String>, Option<String>)
where
B: HttpBody<Data = Bytes> + Send + 'static,
<B as HttpBody>::Error: std::error::Error + Send + Sync,
B::Error: std::error::Error + Send + Sync,
B::Data: Send,
{
let log_context = LogContext::new("test-request");
let response = log_context.run(|| self.dispatch(request)).await;
let requester = log_context.requester().map(ToString::to_string);
(response, requester)
}
/// Get a token with the given scope
pub async fn token_with_scope(&mut self, scope: &str) -> String {
// Provision a client
+9 -1
View File
@@ -17,7 +17,7 @@ use axum::{
use axum_extra::typed_header::TypedHeader;
use hyper::StatusCode;
use mas_axum_utils::{
GenericError, SessionInfoExt,
GenericError, RecordAsRequester, SessionInfoExt,
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
record_error,
@@ -356,6 +356,10 @@ pub(crate) async fn get(
.add(&mut rng, &clock, &user, user_agent)
.await?;
// Attribute this request (and its log line) to the user being
// logged in through the upstream provider.
user.maybe_record_as_requester();
let upstream_session = repo
.upstream_oauth_session()
.consume(&clock, upstream_session, &session)
@@ -648,6 +652,10 @@ pub(crate) async fn get(
.add(&mut rng, &clock, &existing_user, user_agent)
.await?;
// Attribute this request (and its log line) to the user
// being logged in through the upstream provider.
existing_user.maybe_record_as_requester();
let upstream_session = repo
.upstream_oauth_session()
.consume(&clock, upstream_session, &session)
+5 -1
View File
@@ -13,7 +13,7 @@ use axum::{
use axum_extra::{extract::Query, typed_header::TypedHeader};
use hyper::StatusCode;
use mas_axum_utils::{
InternalError, SessionInfoExt,
InternalError, RecordAsRequester, SessionInfoExt,
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
};
@@ -347,6 +347,10 @@ pub(crate) async fn post(
repo.save().await?;
// Attribute the rest of this request (and its log line) to the user that
// just logged in.
user.maybe_record_as_requester();
PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]);
activity_tracker
+5 -1
View File
@@ -9,7 +9,7 @@ use axum::{
response::IntoResponse,
};
use mas_axum_utils::{
InternalError, SessionInfoExt,
InternalError, RecordAsRequester, SessionInfoExt,
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
};
@@ -37,6 +37,10 @@ pub(crate) async fn post(
if let Some(session) = maybe_session
&& session.finished_at.is_none()
{
// Attribute this request (and its log line) to the user being
// logged out.
session.maybe_record_as_requester();
activity_tracker
.record_browser_session(&clock, &session)
.await;
@@ -12,7 +12,7 @@ use axum::{
};
use axum_extra::TypedHeader;
use chrono::Duration;
use mas_axum_utils::{InternalError, SessionInfoExt as _, cookies::CookieJar};
use mas_axum_utils::{InternalError, RecordAsRequester, SessionInfoExt as _, cookies::CookieJar};
use mas_data_model::{BoxClock, BoxRng, SiteConfig};
use mas_matrix::HomeserverConnection;
use mas_router::{PostAuthAction, UrlBuilder};
@@ -307,6 +307,9 @@ pub(crate) async fn get(
.user()
.add(&mut rng, &clock, registration.username)
.await?;
// Attribute this request (and its log line) to the user that was just
// registered and logged in.
user.maybe_record_as_requester();
// Also create a browser session which will log the user in
let user_session = repo
.browser_session()