From a6ec5f46a5ba1cd0141bb580016d08522270a1f8 Mon Sep 17 00:00:00 2001 From: The Morpheus Tutorials <22853222+TheMorpheus407@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:28:37 +0200 Subject: [PATCH 1/2] Keep profile publication choices to the owner's own sign-in Sessions now record who signed in: the owner (password, OAuth or registration), an administrator who signed in to the account (with the administrator's id), the CLI, or "legacy" for sessions from before this change. The origin lives on the session row, so refreshes and restarts keep it. Sharing, making private again and confirming the publication notice are refused with 403 for every session that is not the owner's own sign-in, on the publication route and through the old leaderboard_opt_out flag. Legacy sessions count as the owner's when they carry a device name, because signing in to someone else's account has never set one. The additive migration signs nobody out. Requests made in a session an administrator opened are now recorded in the administrative audit log for that administrator. Co-Authored-By: Claude Opus 5.5 --- academy/src/commands/admin/session.rs | 15 ++- academy/src/commands/migrate/load/auth.rs | 4 +- academy/src/environment/types.rs | 5 +- academy/tests/auth_robustness_native.rs | 11 +- .../rest/src/middlewares/admin_audit.rs | 3 +- academy_api/rest/src/routes/publication.rs | 7 + academy_api/rest/src/routes/user.rs | 5 + academy_core/admin_audit/contracts/src/lib.rs | 6 +- academy_core/admin_audit/impl/src/lib.rs | 51 +++++--- .../admin_audit/impl/src/tests/mod.rs | 2 + .../admin_audit/impl/src/tests/record.rs | 90 ++++++++++++- academy_core/oauth2/impl/src/lib.rs | 11 +- .../oauth2/impl/src/tests/create_session.rs | 11 +- academy_core/session/contracts/src/session.rs | 10 +- academy_core/session/impl/src/lib.rs | 24 +++- academy_core/session/impl/src/session.rs | 77 ++++++++++- .../session/impl/src/tests/create_session.rs | 8 +- .../session/impl/src/tests/impersonate.rs | 17 ++- academy_core/user/contracts/src/lib.rs | 2 + .../user/contracts/src/publication.rs | 2 + academy_core/user/impl/src/lib.rs | 35 ++++- academy_core/user/impl/src/publication.rs | 21 ++- .../user/impl/src/tests/create_user.rs | 6 + academy_core/user/impl/src/tests/mod.rs | 4 +- .../impl/src/tests/update_user/profile.rs | 73 ++++++++++- academy_demo/src/session.rs | 6 +- academy_models/src/admin_audit.rs | 6 +- academy_models/src/session.rs | 83 ++++++++++++ .../postgres/clorinde/src/queries/session.rs | 36 +++++- .../2026-10-04-175659_session_origin/down.sql | 6 + .../2026-10-04-175659_session_origin/up.sql | 19 +++ .../postgres/queries/session.sql | 8 +- academy_persistence/postgres/src/session.rs | 27 +++- .../postgres/tests/common/mod.rs | 60 +++++++-- .../postgres/tests/invoice_identity.rs | 9 +- .../postgres/tests/migrations.rs | 89 ++++++++++++- .../postgres/tests/repos/session.rs | 74 ++++++++++- tests/publication.md | 12 ++ tests/publication.py | 120 ++++++++++++++++++ 39 files changed, 968 insertions(+), 87 deletions(-) create mode 100644 academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql create mode 100644 academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/up.sql diff --git a/academy/src/commands/admin/session.rs b/academy/src/commands/admin/session.rs index 5a5c2fcb..4949e921 100644 --- a/academy/src/commands/admin/session.rs +++ b/academy/src/commands/admin/session.rs @@ -1,7 +1,7 @@ use academy_config::Config; use academy_core_session_contracts::session::SessionService; use academy_di::Provide; -use academy_models::user::UserName; +use academy_models::{session::SessionOrigin, user::UserName}; use academy_persistence_contracts::{Database as _, Transaction, user::UserRepository}; use anyhow::{Context, anyhow}; use clap::Subcommand; @@ -46,8 +46,17 @@ async fn impersonate(config: Config, name: String) -> anyhow::Result<()> { let session_service: types::Session = provider.provide(); let login = session_service // The CLI already requires access to the server, so the session is - // created as if the second factor had been verified. - .create(&mut txn, user_composite, None, false, true) + // created as if the second factor had been verified. It is still + // someone else's sign-in to the account; no administrator account is + // involved to record. + .create( + &mut txn, + user_composite, + None, + false, + true, + SessionOrigin::Impersonation { admin: None }, + ) .await .context("Failed to create session")?; diff --git a/academy/src/commands/migrate/load/auth.rs b/academy/src/commands/migrate/load/auth.rs index 26ba436f..fd32b06d 100644 --- a/academy/src/commands/migrate/load/auth.rs +++ b/academy/src/commands/migrate/load/auth.rs @@ -2,7 +2,7 @@ use academy_models::{ Sha256Hash, mfa::{MfaRecoveryCodeHash, TotpDevice, TotpSecret}, oauth2::{OAuth2Link, OAuth2UserInfo}, - session::{Session, SessionRefreshTokenHash}, + session::{Session, SessionOrigin, SessionRefreshTokenHash}, user::{User, UserInvoiceInfo, UserProfile}, }; use academy_persistence_contracts::{ @@ -162,6 +162,8 @@ pub async fn load(db: PostgresDatabase, auth: DbConnection) -> anyhow::Result<() created_at: last_update.and_utc(), updated_at: last_update.and_utc(), mfa_verified: false, + // The previous system did not record who signed in. + origin: SessionOrigin::Legacy, }; let refresh_token_hash = SessionRefreshTokenHash::new(Sha256Hash( diff --git a/academy/src/environment/types.rs b/academy/src/environment/types.rs index f489fd81..90cddaf6 100644 --- a/academy/src/environment/types.rs +++ b/academy/src/environment/types.rs @@ -168,6 +168,7 @@ pub type UserFeature = UserFeatureServiceImpl< UserRepo, CoinRepo, DocumentRepo, + SessionRepo, >; pub type User = UserServiceImpl; pub type UserEmailConfirmation = @@ -314,7 +315,8 @@ pub type Internal = InternalServiceImpl< HeartRepo, >; -pub type AdminAuditFeature = AdminAuditFeatureServiceImpl; +pub type AdminAuditFeature = + AdminAuditFeatureServiceImpl; pub type PurchaseRepo = academy_persistence_postgres::purchase::PostgresPurchaseRepository; pub type PurchaseFeature = academy_core_purchase_impl::PurchaseFeatureServiceImpl< @@ -357,4 +359,5 @@ pub type PublicationFeature = academy_core_user_impl::publication::PublicationFe Jwt, PublicationRepo, UserRepo, + SessionRepo, >; diff --git a/academy/tests/auth_robustness_native.rs b/academy/tests/auth_robustness_native.rs index a1f9c614..f6ea92e8 100644 --- a/academy/tests/auth_robustness_native.rs +++ b/academy/tests/auth_robustness_native.rs @@ -7,7 +7,7 @@ use academy_core_session_contracts::{SessionFeatureService, session::SessionServ use academy_core_user_contracts::UserFeatureService; use academy_demo::user::{ADMIN, BAR, FOO}; use academy_di::Provide; -use academy_models::{VerificationCode, auth::Login, user::UserComposite}; +use academy_models::{VerificationCode, auth::Login, session::SessionOrigin, user::UserComposite}; use academy_persistence_contracts::{Database, Transaction}; #[path = "../../academy_persistence/postgres/tests/common/mod.rs"] @@ -41,7 +41,14 @@ async fn login( let service: types::Session = provider.provide(); let mut txn = db.begin_transaction().await.unwrap(); let result = service - .create(&mut txn, user.clone(), None, false, mfa) + .create( + &mut txn, + user.clone(), + None, + false, + mfa, + SessionOrigin::SignIn, + ) .await .unwrap(); txn.commit().await.unwrap(); diff --git a/academy_api/rest/src/middlewares/admin_audit.rs b/academy_api/rest/src/middlewares/admin_audit.rs index 71f39b8a..ac74e22a 100644 --- a/academy_api/rest/src/middlewares/admin_audit.rs +++ b/academy_api/rest/src/middlewares/admin_audit.rs @@ -1,5 +1,6 @@ //! Record every state changing request made with an administrator's access -//! token in the administrative audit log, plus the reads listed in +//! token, or in a session an administrator opened in someone else's account, in +//! the administrative audit log, plus the reads listed in //! [`AUDITED_READ_ROUTES`]. use std::sync::Arc; diff --git a/academy_api/rest/src/routes/publication.rs b/academy_api/rest/src/routes/publication.rs index 4ce01f59..167faccd 100644 --- a/academy_api/rest/src/routes/publication.rs +++ b/academy_api/rest/src/routes/publication.rs @@ -92,6 +92,7 @@ fn respond(result: Result) -> Response { "publication_preview_required", ), PublicationError::Unverified => (StatusCode::FORBIDDEN, "email_not_verified"), + PublicationError::NotOwnerSignIn => (StatusCode::FORBIDDEN, "owner_sign_in_required"), }; (status, Json(serde_json::json!({"detail":detail}))).into_response() } @@ -165,6 +166,12 @@ fn preview_docs(op: TransformOperation) -> TransformOperation { } fn choice_docs(op: TransformOperation) -> TransformOperation { op.summary("Record an owner choice with CAS and bounded replay receipts.") + .description( + "Only a session from the owner's own sign-in can share, make private again or confirm \ + the notice. A session an administrator opened in the account is refused with `403` \ + and `owner_sign_in_required`, also after refreshes; so is a session from before \ + origins were recorded that has no device name. Support uses the withdrawal route.", + ) .add_response::(StatusCode::OK, None) } fn epoch_docs(op: TransformOperation) -> TransformOperation { diff --git a/academy_api/rest/src/routes/user.rs b/academy_api/rest/src/routes/user.rs index f7e22b78..3ee5c2af 100644 --- a/academy_api/rest/src/routes/user.rs +++ b/academy_api/rest/src/routes/user.rs @@ -359,6 +359,7 @@ async fn update( | UserUpdateError::NameChangeRateLimit { .. }, ) => PermissionDeniedError.into_response(), Err(UserUpdateError::InvalidVatId) => InvalidVatIdError.into_response(), + Err(UserUpdateError::NotOwnerSignIn) => OwnerSignInRequiredError.into_response(), Err(UserUpdateError::Auth(err)) => auth_error(err), Err(UserUpdateError::Other(err)) => internal_server_error(err), } @@ -372,6 +373,7 @@ fn update_docs(op: TransformOperation) -> TransformOperation { .add_error::() .add_error::() .add_error::() + .add_error::() .add_error::() .with(auth_error_docs) .with(internal_server_error_docs) @@ -669,6 +671,9 @@ error_code! { pub UserDisabledError(FORBIDDEN, "User disabled"); /// The last login method (password or OAuth2 link) cannot be deleted. pub CannotDeleteLastLoginMethodError(FORBIDDEN, "Cannot delete last login method"); + /// Only the owner's own sign-in can change the profile visibility, never a + /// session an administrator opened in the account. + OwnerSignInRequiredError(FORBIDDEN, "Owner sign-in required"); /// A user with this name already exists. UserAlreadyExistsError(CONFLICT, "User already exists"); /// A user with this email address already exists. diff --git a/academy_core/admin_audit/contracts/src/lib.rs b/academy_core/admin_audit/contracts/src/lib.rs index 0da38383..f53e85c1 100644 --- a/academy_core/admin_audit/contracts/src/lib.rs +++ b/academy_core/admin_audit/contracts/src/lib.rs @@ -11,8 +11,10 @@ use thiserror::Error; pub trait AdminAuditFeatureService: Send + Sync + 'static { /// Record a request in the administrative audit log. /// - /// Requests that were not authenticated with an administrator's access - /// token are ignored. Returns whether an entry has been recorded. + /// Requests made in a session an administrator opened in someone else's + /// account are recorded for that administrator. Other requests that were + /// not authenticated with an administrator's access token are ignored. + /// Returns whether an entry has been recorded. fn record( &self, request: AdminAuditRequest, diff --git a/academy_core/admin_audit/impl/src/lib.rs b/academy_core/admin_audit/impl/src/lib.rs index 34a5f58b..583682c1 100644 --- a/academy_core/admin_audit/impl/src/lib.rs +++ b/academy_core/admin_audit/impl/src/lib.rs @@ -5,7 +5,9 @@ use academy_core_admin_audit_contracts::{ }; use academy_di::Build; use academy_models::{admin_audit::AdminAuditLogEntry, auth::AccessToken}; -use academy_persistence_contracts::{Database, Transaction, admin_audit::AdminAuditRepository}; +use academy_persistence_contracts::{ + Database, Transaction, admin_audit::AdminAuditRepository, session::SessionRepository, +}; use academy_shared_contracts::{id::IdService, time::TimeService}; use academy_utils::trace_instrument; use anyhow::Context; @@ -15,22 +17,24 @@ mod tests; #[derive(Debug, Clone, Build)] #[cfg_attr(test, derive(Default))] -pub struct AdminAuditFeatureServiceImpl { +pub struct AdminAuditFeatureServiceImpl { db: Db, auth: Auth, id: Id, time: Time, admin_audit_repo: AdminAuditRepo, + session_repo: SessionRepo, } -impl AdminAuditFeatureService - for AdminAuditFeatureServiceImpl +impl AdminAuditFeatureService + for AdminAuditFeatureServiceImpl where Db: Database, Auth: AuthService, Id: IdService, Time: TimeService, AdminAuditRepo: AdminAuditRepository, + SessionRepo: SessionRepository, { // The request carries the access token it was made with. #[trace_instrument(skip(self, request), fields(path = %*request.path))] @@ -41,29 +45,44 @@ where return Ok(false); }; + let mut txn = self.db.begin_transaction().await?; + + // A session an administrator opened in someone else's account carries + // that account's token. Its requests are the administrator's and act + // on that account. + let impersonated_by = self + .session_repo + .get(&mut txn, auth.session_id) + .await + .context("Failed to get session from database")? + .and_then(|session| session.origin.impersonated_by()); + + let target_user_id = target_user_id( + &request.path, + request.route.as_deref().map(String::as_str), + auth.user_id, + ); + // The entry is written whenever the request was made with an - // administrator's token, including for requests that were rejected. - if !auth.admin { - return Ok(false); - } + // administrator's token or in a session an administrator opened, + // including for requests that were rejected. + let (admin_user_id, target_user_id) = match impersonated_by { + Some(admin) => (admin, target_user_id.or(Some(auth.user_id))), + None if auth.admin => (auth.user_id, target_user_id), + None => return Ok(false), + }; let entry = AdminAuditLogEntry { id: self.id.generate(), at: self.time.now(), - admin_user_id: auth.user_id, - target_user_id: target_user_id( - &request.path, - request.route.as_deref().map(String::as_str), - auth.user_id, - ), + admin_user_id, + target_user_id, method: request.method, path: request.path, status: request.status, request_id: request.request_id, }; - let mut txn = self.db.begin_transaction().await?; - self.admin_audit_repo .create(&mut txn, &entry) .await diff --git a/academy_core/admin_audit/impl/src/tests/mod.rs b/academy_core/admin_audit/impl/src/tests/mod.rs index 9b466e48..1af2d14f 100644 --- a/academy_core/admin_audit/impl/src/tests/mod.rs +++ b/academy_core/admin_audit/impl/src/tests/mod.rs @@ -4,6 +4,7 @@ use academy_demo::{UUID1, user::ADMIN}; use academy_models::admin_audit::AdminAuditLogEntry; use academy_persistence_contracts::{ MockDatabase, MockTransaction, admin_audit::MockAdminAuditRepository, + session::MockSessionRepository, }; use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; use chrono::{DateTime, TimeZone, Utc}; @@ -19,6 +20,7 @@ type Sut = AdminAuditFeatureServiceImpl< MockIdService, MockTimeService, MockAdminAuditRepository, + MockSessionRepository, >; fn now() -> DateTime { diff --git a/academy_core/admin_audit/impl/src/tests/record.rs b/academy_core/admin_audit/impl/src/tests/record.rs index 17724f86..40f9ed8d 100644 --- a/academy_core/admin_audit/impl/src/tests/record.rs +++ b/academy_core/admin_audit/impl/src/tests/record.rs @@ -1,11 +1,17 @@ use academy_auth_contracts::MockAuthService; use academy_core_admin_audit_contracts::{AdminAuditFeatureService, AdminAuditRequest}; use academy_demo::{ + UUID1, session::{ADMIN_1, FOO_1}, user::{ADMIN, FOO}, }; -use academy_models::admin_audit::AdminAuditLogEntry; -use academy_persistence_contracts::{MockDatabase, admin_audit::MockAdminAuditRepository}; +use academy_models::{ + admin_audit::AdminAuditLogEntry, + session::{Session, SessionOrigin}, +}; +use academy_persistence_contracts::{ + MockDatabase, admin_audit::MockAdminAuditRepository, session::MockSessionRepository, +}; use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; use super::{Sut, make_entry, make_request, now}; @@ -18,6 +24,28 @@ fn sut(expected: AdminAuditLogEntry) -> Sut { id: MockIdService::new().with_generate(expected.id), time: MockTimeService::new().with_now(now()), admin_audit_repo: MockAdminAuditRepository::new().with_create(expected), + session_repo: MockSessionRepository::new().with_get(ADMIN_1.id, Some(ADMIN_1.clone())), + } +} + +/// A session the administrator opened in FOO's account. +fn impersonation(admin: Option) -> Session { + Session { + id: UUID1.into(), + device_name: None, + mfa_verified: false, + origin: SessionOrigin::Impersonation { admin }, + ..FOO_1.clone() + } +} + +fn publication_request() -> AdminAuditRequest { + AdminAuditRequest { + method: "PUT".try_into().unwrap(), + path: "/auth/users/me/publication".try_into().unwrap(), + route: Some("/auth/users/me/publication".try_into().unwrap()), + status: 403, + ..make_request() } } @@ -77,6 +105,62 @@ async fn admin_without_route() { assert!(result.unwrap()); } +/// Requests in a session an administrator opened in someone else's account +/// carry that account's token. They are recorded for the administrator and act +/// on that account, including a refused attempt to share its profile. +#[tokio::test] +async fn impersonation_session_recorded_for_admin() { + // Arrange + let session = impersonation(Some(ADMIN.user.id)); + let request = publication_request(); + let expected = AdminAuditLogEntry { + id: UUID1.into(), + at: now(), + admin_user_id: ADMIN.user.id, + method: request.method.clone(), + path: request.path.clone(), + target_user_id: Some(FOO.user.id), + status: 403, + request_id: request.request_id.clone(), + }; + + let sut = AdminAuditFeatureServiceImpl { + db: MockDatabase::build(true), + auth: MockAuthService::new().with_authenticate(Some((FOO.user.clone(), session.clone()))), + id: MockIdService::new().with_generate(expected.id), + time: MockTimeService::new().with_now(now()), + admin_audit_repo: MockAdminAuditRepository::new().with_create(expected), + session_repo: MockSessionRepository::new().with_get(session.id, Some(session)), + }; + + // Act + let result = sut.record(request).await; + + // Assert + assert!(result.unwrap()); +} + +/// The CLI signs in without an administrator account, so there is nobody to +/// attribute its requests to. +#[tokio::test] +async fn cli_impersonation_session() { + // Arrange + let session = impersonation(None); + + let sut = AdminAuditFeatureServiceImpl { + db: MockDatabase::build(false), + auth: MockAuthService::new().with_authenticate(Some((FOO.user.clone(), session.clone()))), + session_repo: MockSessionRepository::new().with_get(session.id, Some(session)), + ..Sut::default() + }; + + // Act + let result = sut.record(publication_request()).await; + + // Assert + assert!(!result.unwrap()); +} + #[tokio::test] async fn no_admin() { // Arrange @@ -84,6 +168,8 @@ async fn no_admin() { let sut = AdminAuditFeatureServiceImpl { auth, + db: MockDatabase::build(false), + session_repo: MockSessionRepository::new().with_get(FOO_1.id, Some(FOO_1.clone())), ..Sut::default() }; diff --git a/academy_core/oauth2/impl/src/lib.rs b/academy_core/oauth2/impl/src/lib.rs index e8847a1a..80953d76 100644 --- a/academy_core/oauth2/impl/src/lib.rs +++ b/academy_core/oauth2/impl/src/lib.rs @@ -18,7 +18,7 @@ use academy_models::{ OAuth2Provider, OAuth2ProviderId, OAuth2ProviderSummary, OAuth2Registration, OAuth2UserInfo, }, - session::DeviceName, + session::{DeviceName, SessionOrigin}, url::Url, user::{UserId, UserIdOrSelf}, }; @@ -423,7 +423,14 @@ where // session does not grant administrative privileges. let login = self .session - .create(&mut txn, user_composite, device_name, true, false) + .create( + &mut txn, + user_composite, + device_name, + true, + false, + SessionOrigin::SignIn, + ) .await .context("Failed to create session")?; diff --git a/academy_core/oauth2/impl/src/tests/create_session.rs b/academy_core/oauth2/impl/src/tests/create_session.rs index bfa0431d..05589c19 100644 --- a/academy_core/oauth2/impl/src/tests/create_session.rs +++ b/academy_core/oauth2/impl/src/tests/create_session.rs @@ -13,6 +13,7 @@ use academy_demo::{ use academy_models::{ auth::Login, oauth2::{OAuth2PendingAuthorization, OAuth2RegistrationToken}, + session::SessionOrigin, }; use academy_persistence_contracts::{MockDatabase, user::MockUserRepository}; use academy_utils::{Apply, assert_matches}; @@ -47,8 +48,14 @@ async fn ok() { Some(FOO.clone()), ); - let session = - MockSessionService::new().with_create(FOO.clone(), None, true, false, expected.clone()); + let session = MockSessionService::new().with_create( + FOO.clone(), + None, + true, + false, + SessionOrigin::SignIn, + expected.clone(), + ); let sut = OAuth2FeatureServiceImpl { db, diff --git a/academy_core/session/contracts/src/session.rs b/academy_core/session/contracts/src/session.rs index c3fa256e..e32f161c 100644 --- a/academy_core/session/contracts/src/session.rs +++ b/academy_core/session/contracts/src/session.rs @@ -2,7 +2,7 @@ use std::future::Future; use academy_models::{ auth::Login, - session::{DeviceName, SessionId, SessionRefreshTokenHash}, + session::{DeviceName, SessionId, SessionOrigin, SessionRefreshTokenHash}, user::{UserComposite, UserId}, }; use thiserror::Error; @@ -14,6 +14,9 @@ pub trait SessionService: Send + Sync + 'static { /// `mfa_verified` records whether the second factor of the user was /// verified before the session was created. Only sessions created with a /// verified second factor grant administrative privileges. + /// + /// `origin` records who signed in. It is stored with the session and + /// survives every refresh. fn create( &self, txn: &mut Txn, @@ -21,6 +24,7 @@ pub trait SessionService: Send + Sync + 'static { device_name: Option, update_last_login: bool, mfa_verified: bool, + origin: SessionOrigin, ) -> impl Future> + Send; /// Refresh the given session by invalidating the current access/refresh @@ -90,6 +94,7 @@ impl MockSessionService { device_name: Option, update_last_login: bool, mfa_verified: bool, + origin: SessionOrigin, result: Login, ) -> Self { self.expect_create() @@ -100,8 +105,9 @@ impl MockSessionService { mockall::predicate::eq(device_name), mockall::predicate::eq(update_last_login), mockall::predicate::eq(mfa_verified), + mockall::predicate::eq(origin), ) - .return_once(|_, _, _, _, _| Box::pin(std::future::ready(Ok(result)))); + .return_once(|_, _, _, _, _, _| Box::pin(std::future::ready(Ok(result)))); self } diff --git a/academy_core/session/impl/src/lib.rs b/academy_core/session/impl/src/lib.rs index 2824ebf7..3170cad1 100644 --- a/academy_core/session/impl/src/lib.rs +++ b/academy_core/session/impl/src/lib.rs @@ -16,7 +16,7 @@ use academy_di::Build; use academy_models::{ RecaptchaResponse, auth::{AccessToken, Login, RefreshToken}, - session::{Session, SessionId}, + session::{Session, SessionId, SessionOrigin}, user::{UserId, UserIdOrSelf, UserNameOrEmailAddress}, }; use academy_persistence_contracts::{ @@ -151,7 +151,14 @@ where let login = self .session - .create(&mut txn, user_composite, device_name, true, mfa_verified) + .create( + &mut txn, + user_composite, + device_name, + true, + mfa_verified, + SessionOrigin::SignIn, + ) .await .context("Failed to create session")?; @@ -216,9 +223,20 @@ where // Impersonation never involves the second factor of the impersonated // user, so the new session does not grant administrative privileges. + // The session records the administrator, which keeps owner-only + // decisions out of its reach for its whole lifetime. let login = self .session - .create(&mut txn, user_composite, None, false, false) + .create( + &mut txn, + user_composite, + None, + false, + false, + SessionOrigin::Impersonation { + admin: Some(auth.user_id), + }, + ) .await .context("Failed to create session")?; diff --git a/academy_core/session/impl/src/session.rs b/academy_core/session/impl/src/session.rs index 987f6348..4d50fa84 100644 --- a/academy_core/session/impl/src/session.rs +++ b/academy_core/session/impl/src/session.rs @@ -3,7 +3,7 @@ use academy_core_session_contracts::session::{SessionRefreshError, SessionServic use academy_di::Build; use academy_models::{ auth::Login, - session::{DeviceName, Session, SessionId, SessionPatch}, + session::{DeviceName, Session, SessionId, SessionOrigin, SessionPatch}, user::{UserComposite, UserId, UserPatch}, }; use academy_persistence_contracts::{session::SessionRepository, user::UserRepository}; @@ -40,6 +40,7 @@ where device_name: Option, update_last_login: bool, mfa_verified: bool, + origin: SessionOrigin, ) -> anyhow::Result { anyhow::ensure!( user_composite.user.enabled, @@ -55,6 +56,7 @@ where created_at: now, updated_at: now, mfa_verified, + origin, }; let tokens = self @@ -259,6 +261,7 @@ mod tests { created_at: FOO_1.created_at, updated_at: FOO_1.created_at, mfa_verified: true, + origin: SessionOrigin::SignIn, }, access_token: tokens.access_token.clone(), refresh_token: tokens.refresh_token.clone(), @@ -293,7 +296,14 @@ mod tests { // Act let result = sut - .create(&mut (), FOO.clone(), FOO_1.device_name.clone(), true, true) + .create( + &mut (), + FOO.clone(), + FOO_1.device_name.clone(), + true, + true, + SessionOrigin::SignIn, + ) .await; // Assert @@ -318,6 +328,7 @@ mod tests { created_at: FOO_1.created_at, updated_at: FOO_1.created_at, mfa_verified: true, + origin: SessionOrigin::SignIn, }, access_token: tokens.access_token.clone(), refresh_token: tokens.refresh_token.clone(), @@ -348,7 +359,14 @@ mod tests { // Act let result = sut - .create(&mut (), FOO.clone(), FOO_1.device_name.clone(), false, true) + .create( + &mut (), + FOO.clone(), + FOO_1.device_name.clone(), + false, + true, + SessionOrigin::SignIn, + ) .await; // Assert @@ -472,6 +490,59 @@ mod tests { assert!(!result.unwrap().session.mfa_verified); } + /// A session an administrator opened stays theirs: a refresh issues new + /// tokens for the stored row and never turns it into the owner's sign-in. + #[tokio::test] + async fn refresh_keeps_the_stored_origin() { + // Arrange + let tokens = Tokens { + access_token: "the new access token".into(), + refresh_token: "the new refresh token".into(), + refresh_token_hash: (*SHA256HASH2).into(), + }; + let stored = Session { + device_name: None, + origin: SessionOrigin::Impersonation { + admin: Some(ADMIN.user.id), + }, + ..FOO_1.clone() + }; + let updated_at = FOO_1.updated_at + Duration::from_secs(3600); + + let auth = + MockAuthService::new().with_issue_tokens(FOO.user.clone(), FOO_1.id, false, tokens); + let auth_access_token = + MockAuthAccessTokenService::new().with_invalidate((*SHA256HASH1).into()); + let time = MockTimeService::new().with_now(updated_at); + let user_repo = + MockUserRepository::new().with_get_composite(FOO_1.user_id, Some(FOO.clone())); + let session_repo = MockSessionRepository::new() + .with_get_refresh_token_hash(FOO_1.id, Some((*SHA256HASH1).into())) + .with_get(FOO_1.id, Some(stored.clone())) + .with_update( + FOO_1.id, + SessionPatch::new().update_updated_at(updated_at), + true, + ) + .with_save_refresh_token_hash(FOO_1.id, (*SHA256HASH2).into()); + + let sut = SessionServiceImpl { + auth, + auth_access_token, + time, + session_repo, + user_repo, + ..Sut::default() + }; + + // Act + let session = sut.refresh(&mut (), FOO_1.id).await.unwrap().session; + + // Assert + assert_eq!(session.origin, stored.origin); + assert!(!session.is_owner_sign_in()); + } + #[tokio::test] async fn refresh_token_hash_not_found() { // Arrange diff --git a/academy_core/session/impl/src/tests/create_session.rs b/academy_core/session/impl/src/tests/create_session.rs index ba3b7d5c..808e5c2e 100644 --- a/academy_core/session/impl/src/tests/create_session.rs +++ b/academy_core/session/impl/src/tests/create_session.rs @@ -13,7 +13,9 @@ use academy_demo::{ session::{BAR_1, FOO_1}, user::{BAR, BAR_PASSWORD, FOO, FOO_PASSWORD}, }; -use academy_models::{auth::Login, mfa::MfaAuthentication, user::UserNameOrEmailAddress}; +use academy_models::{ + auth::Login, mfa::MfaAuthentication, session::SessionOrigin, user::UserNameOrEmailAddress, +}; use academy_persistence_contracts::{MockDatabase, user::MockUserRepository}; use academy_shared_contracts::captcha::{CaptchaCheckError, MockCaptchaService}; use academy_utils::{Apply, assert_matches}; @@ -63,6 +65,7 @@ async fn ok() { cmd.device_name.clone(), true, false, + SessionOrigin::SignIn, expected.clone(), ); @@ -141,6 +144,7 @@ async fn ok_mfa() { cmd.device_name.clone(), true, true, + SessionOrigin::SignIn, expected.clone(), ); @@ -225,6 +229,7 @@ async fn ok_mfa_reset() { cmd.device_name.clone(), true, false, + SessionOrigin::SignIn, expected.clone(), ); @@ -295,6 +300,7 @@ async fn ok_captcha() { cmd.device_name.clone(), true, false, + SessionOrigin::SignIn, expected.clone(), ); diff --git a/academy_core/session/impl/src/tests/impersonate.rs b/academy_core/session/impl/src/tests/impersonate.rs index e51681f1..1a5d8d6e 100644 --- a/academy_core/session/impl/src/tests/impersonate.rs +++ b/academy_core/session/impl/src/tests/impersonate.rs @@ -6,7 +6,10 @@ use academy_demo::{ session::{ADMIN_1, BAR_1, FOO_1}, user::{ADMIN, BAR, FOO}, }; -use academy_models::auth::{AuthError, AuthenticateError, AuthorizeError, Login}; +use academy_models::{ + auth::{AuthError, AuthenticateError, AuthorizeError, Login}, + session::SessionOrigin, +}; use academy_persistence_contracts::{MockDatabase, user::MockUserRepository}; use academy_utils::assert_matches; @@ -33,8 +36,16 @@ async fn ok() { .with_lock_account(FOO.user.id, true) .with_get_composite(FOO.user.id, Some(FOO.clone())); - let session = - MockSessionService::new().with_create(FOO.clone(), None, false, false, expected.clone()); + let session = MockSessionService::new().with_create( + FOO.clone(), + None, + false, + false, + SessionOrigin::Impersonation { + admin: Some(ADMIN.user.id), + }, + expected.clone(), + ); let sut = SessionFeatureServiceImpl { auth, diff --git a/academy_core/user/contracts/src/lib.rs b/academy_core/user/contracts/src/lib.rs index c73f83da..472a02f7 100644 --- a/academy_core/user/contracts/src/lib.rs +++ b/academy_core/user/contracts/src/lib.rs @@ -255,6 +255,8 @@ pub enum UserUpdateError { CannotDisableSelf, #[error("The user cannot change their own admin status.")] CannotDemoteSelf, + #[error("Only the owner's own sign-in can change the profile visibility.")] + NotOwnerSignIn, #[error("The user cannot change their name until {until}.")] NameChangeRateLimit { until: DateTime }, #[error("The vat id is invalid.")] diff --git a/academy_core/user/contracts/src/publication.rs b/academy_core/user/contracts/src/publication.rs index 47aa40ed..dba77fa4 100644 --- a/academy_core/user/contracts/src/publication.rs +++ b/academy_core/user/contracts/src/publication.rs @@ -61,6 +61,8 @@ pub enum PublicationError { InvalidPreview, #[error("Verify the account email before sharing.")] Unverified, + #[error("Only the owner's own sign-in can change the publication.")] + NotOwnerSignIn, #[error(transparent)] Other(#[from] anyhow::Error), } diff --git a/academy_core/user/impl/src/lib.rs b/academy_core/user/impl/src/lib.rs index 3a13c617..d091f954 100644 --- a/academy_core/user/impl/src/lib.rs +++ b/academy_core/user/impl/src/lib.rs @@ -28,12 +28,12 @@ use academy_models::{ auth::{AccessToken, Login}, email_address::EmailAddress, finance::RETENTION_MARKER, - session::DeviceName, + session::{DeviceName, SessionOrigin}, user::{TermsVersion, UserComposite, UserId, UserIdOrSelf, UserInvoiceInfoPatch, UserPassword}, }; use academy_persistence_contracts::{ Database, Transaction, coin::CoinRepository, finance::FinancialDocumentRepository, - user::UserRepository, + session::SessionRepository, user::UserRepository, }; use academy_shared_contracts::captcha::{CaptchaCheckError, CaptchaService}; use academy_utils::{ @@ -71,6 +71,7 @@ pub struct UserFeatureServiceImpl< UserRepo, CoinRepo, DocumentRepo, + SessionRepo, > { db: Db, auth: Auth, @@ -88,6 +89,7 @@ pub struct UserFeatureServiceImpl< user_repo: UserRepo, coin_repo: CoinRepo, document_repo: DocumentRepo, + session_repo: SessionRepo, config: UserFeatureConfig, } @@ -128,6 +130,7 @@ impl< UserRepo, CoinRepo, DocumentRepo, + SessionRepo, > UserFeatureService for UserFeatureServiceImpl< Db, @@ -146,6 +149,7 @@ impl< UserRepo, CoinRepo, DocumentRepo, + SessionRepo, > where Db: Database, @@ -164,6 +168,7 @@ where UserRepo: UserRepository, CoinRepo: CoinRepository, DocumentRepo: FinancialDocumentRepository, + SessionRepo: SessionRepository, { // The query carries the name and email address filters an administrator // typed. @@ -280,7 +285,14 @@ where let result = self .session - .create(&mut txn, user, device_name, true, false) + .create( + &mut txn, + user, + device_name, + true, + false, + SessionOrigin::SignIn, + ) .await .context("Failed to create session")?; @@ -404,6 +416,21 @@ where return Err(UserUpdateError::CannotDemoteSelf); } + // Changing the old opt-out flag shares or withdraws the profile, which + // only the owner's own sign-in decides (see the publication route). + // Support changes another account with its own token instead. + if profile_update.leaderboard_opt_out.is_update() + && user_id == auth.user_id + && !self + .session_repo + .get(&mut txn, auth.session_id) + .await + .context("Failed to get session from database")? + .is_some_and(|session| session.is_owner_sign_in()) + { + return Err(UserUpdateError::NotOwnerSignIn); + } + if let PatchValue::Update(Some(vat_id)) = &invoice_info_update.vat_id && !self .vat_api @@ -937,6 +964,7 @@ impl< UserRepo, CoinRepo, DocumentRepo, + SessionRepo, > UserFeatureServiceImpl< Db, @@ -955,6 +983,7 @@ impl< UserRepo, CoinRepo, DocumentRepo, + SessionRepo, > where Db: Database, diff --git a/academy_core/user/impl/src/publication.rs b/academy_core/user/impl/src/publication.rs index b3b02b3e..e63f1ebc 100644 --- a/academy_core/user/impl/src/publication.rs +++ b/academy_core/user/impl/src/publication.rs @@ -15,18 +15,20 @@ use academy_models::{ use academy_persistence_contracts::{ Database, Transaction, publication::{PublicationRepository, PublicationWriteError}, + session::SessionRepository, user::UserRepository, }; use academy_shared_contracts::jwt::JwtService; #[derive(Debug, Clone, Build)] -pub struct PublicationFeatureServiceImpl { +pub struct PublicationFeatureServiceImpl { db: Db, auth: Auth, auth_internal: AuthInternal, jwt: Jwt, repo: Repo, user_repo: UserRepo, + session_repo: SessionRepo, config: PublicationConfig, } @@ -41,8 +43,8 @@ fn publication_error(error: PublicationWriteError) -> PublicationError { } } -impl PublicationFeatureService - for PublicationFeatureServiceImpl +impl PublicationFeatureService + for PublicationFeatureServiceImpl where Db: Database, Auth: AuthService, @@ -50,6 +52,7 @@ where Jwt: JwtService, Repo: PublicationRepository, UserRepo: UserRepository, + SessionRepo: SessionRepository, { async fn support_settings( &self, @@ -201,6 +204,18 @@ where .authenticate_in_transaction(&mut txn, token) .await .map_auth_err()?; + // Sharing, making private again and confirming the notice are the + // owner's own decisions, recorded as such. A session someone else + // opened in the account never makes them, however often it is + // refreshed; support withdraws through its own route instead. + let owner_sign_in = self + .session_repo + .get(&mut txn, auth.session_id) + .await? + .is_some_and(|session| session.is_owner_sign_in()); + if !owner_sign_in { + return Err(PublicationError::NotOwnerSignIn); + } let preview_valid = choice .preview_token .as_deref() diff --git a/academy_core/user/impl/src/tests/create_user.rs b/academy_core/user/impl/src/tests/create_user.rs index 67e7789a..cff507f0 100644 --- a/academy_core/user/impl/src/tests/create_user.rs +++ b/academy_core/user/impl/src/tests/create_user.rs @@ -12,6 +12,7 @@ use academy_demo::{ use academy_models::{ auth::Login, oauth2::{OAuth2Registration, OAuth2RegistrationToken}, + session::SessionOrigin, }; use academy_persistence_contracts::{MockDatabase, MockTransaction}; use academy_shared_contracts::captcha::{CaptchaCheckError, MockCaptchaService}; @@ -53,6 +54,7 @@ async fn ok() { FOO_1.device_name.clone(), true, false, + SessionOrigin::SignIn, expected.clone(), ); @@ -147,6 +149,7 @@ async fn failed_commit_returns_error_instead_of_login() { None, true, false, + SessionOrigin::SignIn, Login { user_composite: FOO.clone(), session: FOO_1.clone(), @@ -214,6 +217,7 @@ async fn ok_oauth2() { FOO_1.device_name.clone(), true, false, + SessionOrigin::SignIn, expected.clone(), ); @@ -280,6 +284,7 @@ async fn oauth2_registration_token_already_used() { FOO_1.device_name.clone(), true, false, + SessionOrigin::SignIn, Login { user_composite: FOO.clone(), session: FOO_1.clone(), @@ -658,6 +663,7 @@ async fn registration_override_is_explicit_and_records_only_the_offered_version( FOO_1.device_name.clone(), true, false, + SessionOrigin::SignIn, expected.clone(), ); assert_eq!( diff --git a/academy_core/user/impl/src/tests/mod.rs b/academy_core/user/impl/src/tests/mod.rs index 8a67056a..2d95e728 100644 --- a/academy_core/user/impl/src/tests/mod.rs +++ b/academy_core/user/impl/src/tests/mod.rs @@ -15,7 +15,8 @@ use academy_extern_contracts::{ use academy_models::user::TermsVersion; use academy_persistence_contracts::{ MockDatabase, MockTransaction, coin::MockCoinRepository, - finance::MockFinancialDocumentRepository, user::MockUserRepository, + finance::MockFinancialDocumentRepository, session::MockSessionRepository, + user::MockUserRepository, }; use academy_shared_contracts::captcha::MockCaptchaService; @@ -54,6 +55,7 @@ type Sut = UserFeatureServiceImpl< MockUserRepository, MockCoinRepository, MockFinancialDocumentRepository, + MockSessionRepository, >; impl Default for UserFeatureConfig { diff --git a/academy_core/user/impl/src/tests/update_user/profile.rs b/academy_core/user/impl/src/tests/update_user/profile.rs index 3d759534..f4a554fd 100644 --- a/academy_core/user/impl/src/tests/update_user/profile.rs +++ b/academy_core/user/impl/src/tests/update_user/profile.rs @@ -1,12 +1,21 @@ use academy_auth_contracts::MockAuthService; -use academy_core_user_contracts::{UserFeatureService, UserUpdateRequest}; +use academy_core_user_contracts::{UserFeatureService, UserUpdateError, UserUpdateRequest}; use academy_demo::{ + UUID1, session::FOO_1, - user::{BAR, FOO}, + user::{ADMIN, BAR, FOO}, +}; +use academy_models::{ + session::{Session, SessionOrigin}, + user::{UserComposite, UserIdOrSelf, UserProfile, UserProfilePatch}, +}; +use academy_persistence_contracts::{ + MockDatabase, session::MockSessionRepository, user::MockUserRepository, +}; +use academy_utils::{ + assert_matches, + patch::{Patch, PatchValue}, }; -use academy_models::user::{UserComposite, UserIdOrSelf, UserProfile, UserProfilePatch}; -use academy_persistence_contracts::{MockDatabase, user::MockUserRepository}; -use academy_utils::patch::{Patch, PatchValue}; use crate::{UserFeatureServiceImpl, tests::Sut}; @@ -122,11 +131,13 @@ async fn update_leaderboard_opt_out() { }, true, ); + let session_repo = MockSessionRepository::new().with_get(FOO_1.id, Some(FOO_1.clone())); let sut = UserFeatureServiceImpl { auth, db, user_repo, + session_repo, ..Sut::default() }; @@ -148,3 +159,55 @@ async fn update_leaderboard_opt_out() { // Assert assert_eq!(result.unwrap(), expected); } + +/// The old opt-out flag shares or withdraws the profile, so a session an +/// administrator opened in the account cannot change it. +#[tokio::test] +async fn update_leaderboard_opt_out_from_impersonation() { + // Arrange + let session = Session { + id: UUID1.into(), + device_name: None, + origin: SessionOrigin::Impersonation { + admin: Some(ADMIN.user.id), + }, + ..FOO_1.clone() + }; + + let auth = MockAuthService::new() + .with_authenticate(Some((FOO.user.clone(), session.clone()))) + .with_authenticate_in_transaction(Some((FOO.user.clone(), session.clone()))); + + let db = MockDatabase::build(false); + + let user_repo = MockUserRepository::new() + .with_lock_account(FOO.user.id, true) + .with_get_composite(FOO.user.id, Some(FOO.clone())); + let session_repo = MockSessionRepository::new().with_get(session.id, Some(session)); + + let sut = UserFeatureServiceImpl { + auth, + db, + user_repo, + session_repo, + ..Sut::default() + }; + + // Act + let result = sut + .update_user( + &"token".into(), + UserIdOrSelf::Slf, + UserUpdateRequest { + profile: UserProfilePatch { + leaderboard_opt_out: PatchValue::Update(true), + ..Default::default() + }, + ..Default::default() + }, + ) + .await; + + // Assert + assert_matches!(result, Err(UserUpdateError::NotOwnerSignIn)); +} diff --git a/academy_demo/src/session.rs b/academy_demo/src/session.rs index 5330df1c..d32b5043 100644 --- a/academy_demo/src/session.rs +++ b/academy_demo/src/session.rs @@ -1,6 +1,6 @@ use std::{sync::LazyLock, time::Duration}; -use academy_models::session::Session; +use academy_models::session::{Session, SessionOrigin}; use academy_persistence_contracts::session::SessionRepository; use uuid::uuid; @@ -16,6 +16,7 @@ pub static ADMIN_1: LazyLock = LazyLock::new(|| Session { updated_at: ADMIN.user.created_at + Duration::from_secs(1337), // administrative privileges require a session created with a second factor mfa_verified: true, + origin: SessionOrigin::SignIn, }); pub static FOO_1: LazyLock = LazyLock::new(|| Session { @@ -25,6 +26,7 @@ pub static FOO_1: LazyLock = LazyLock::new(|| Session { created_at: FOO.user.created_at + Duration::from_secs(42), updated_at: FOO.user.created_at + Duration::from_secs(1337), mfa_verified: false, + origin: SessionOrigin::SignIn, }); pub static FOO_2: LazyLock = LazyLock::new(|| Session { @@ -34,6 +36,7 @@ pub static FOO_2: LazyLock = LazyLock::new(|| Session { created_at: FOO.user.created_at, updated_at: FOO.user.created_at + Duration::from_secs(17), mfa_verified: false, + origin: SessionOrigin::SignIn, }); pub static BAR_1: LazyLock = LazyLock::new(|| Session { @@ -43,6 +46,7 @@ pub static BAR_1: LazyLock = LazyLock::new(|| Session { created_at: BAR.user.created_at, updated_at: BAR.user.created_at + Duration::from_secs(23), mfa_verified: false, + origin: SessionOrigin::SignIn, }); pub async fn create( diff --git a/academy_models/src/admin_audit.rs b/academy_models/src/admin_audit.rs index 3e63f096..505e75e0 100644 --- a/academy_models/src/admin_audit.rs +++ b/academy_models/src/admin_audit.rs @@ -12,7 +12,8 @@ id!(AdminAuditLogEntryId); pub const ADMIN_AUDIT_LOG_RETENTION_MONTHS: u32 = 12; /// A single state changing request that was authenticated with an -/// administrator's access token. +/// administrator's access token, or made in a session an administrator opened +/// in someone else's account. /// /// Only request metadata is recorded, never request bodies. #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,7 +21,8 @@ pub struct AdminAuditLogEntry { pub id: AdminAuditLogEntryId, /// Time at which the request was answered pub at: DateTime, - /// The administrator whose access token authenticated the request + /// The administrator whose access token authenticated the request, or who + /// opened the session in someone else's account it was made in pub admin_user_id: UserId, /// HTTP method of the request pub method: RequestMethod, diff --git a/academy_models/src/session.rs b/academy_models/src/session.rs index 11cc411d..7d13f27b 100644 --- a/academy_models/src/session.rs +++ b/academy_models/src/session.rs @@ -24,6 +24,51 @@ pub struct Session { /// administrative privileges, even if the account is an administrator. #[no_patch] pub mfa_verified: bool, + /// How the session came into existence. Recorded once and kept across + /// refreshes. + #[no_patch] + pub origin: SessionOrigin, +} + +impl Session { + /// Whether this session belongs to the account owner's own sign-in. + /// + /// Decisions only the owner may make, such as sharing the profile, require + /// such a session. Sessions from before origins were recorded count as the + /// owner's own sign-in only if they carry a device name: every way of + /// signing in to someone else's account (the admin API and the CLI) has + /// always created sessions without one, and a session's device name is + /// never changed later. + pub fn is_owner_sign_in(&self) -> bool { + match self.origin { + SessionOrigin::SignIn => true, + SessionOrigin::Impersonation { .. } => false, + SessionOrigin::Legacy => self.device_name.is_some(), + } + } +} + +/// How a session came into existence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionOrigin { + /// The account owner signed in (password, OAuth or registration). + SignIn, + /// Someone else signed in to the account: an administrator through the API + /// (`admin` is set), or an operator with server access through the CLI. + Impersonation { admin: Option }, + /// Created before origins were recorded, or by a version of the backend + /// that does not record them. + Legacy, +} + +impl SessionOrigin { + /// The administrator who signed in to the account, if known. + pub fn impersonated_by(self) -> Option { + match self { + Self::Impersonation { admin } => admin, + Self::SignIn | Self::Legacy => None, + } + } } nutype_string!(DeviceName(validate(len_char_max = DeviceName::MAX_LEN))); @@ -64,6 +109,44 @@ mod tests { assert_eq!(result.into_inner(), expected); } + #[test] + fn only_the_owners_sign_in_counts_as_owner() { + let session = |origin, device_name: Option<&str>| Session { + id: uuid::Uuid::nil().into(), + user_id: uuid::Uuid::nil().into(), + device_name: device_name.map(|name| DeviceName::try_new(name.to_owned()).unwrap()), + created_at: DateTime::UNIX_EPOCH, + updated_at: DateTime::UNIX_EPOCH, + mfa_verified: false, + origin, + }; + let admin = Some(uuid::Uuid::max().into()); + for (origin, device_name, expected) in [ + (SessionOrigin::SignIn, Some("Firefox"), true), + (SessionOrigin::SignIn, None, true), + (SessionOrigin::Impersonation { admin }, None, false), + ( + SessionOrigin::Impersonation { admin }, + Some("Firefox"), + false, + ), + (SessionOrigin::Impersonation { admin: None }, None, false), + (SessionOrigin::Legacy, Some("Firefox"), true), + (SessionOrigin::Legacy, None, false), + ] { + assert_eq!( + session(origin, device_name).is_owner_sign_in(), + expected, + "{origin:?} {device_name:?}" + ); + } + assert_eq!( + SessionOrigin::Impersonation { admin }.impersonated_by(), + admin + ); + assert_eq!(SessionOrigin::Legacy.impersonated_by(), None); + } + #[test] fn device_name_truncation_preserves_unicode_characters() { for input in [ diff --git a/academy_persistence/postgres/clorinde/src/queries/session.rs b/academy_persistence/postgres/clorinde/src/queries/session.rs index a3bb9a97..4d9d3039 100644 --- a/academy_persistence/postgres/clorinde/src/queries/session.rs +++ b/academy_persistence/postgres/clorinde/src/queries/session.rs @@ -1,13 +1,15 @@ // This file was generated with `clorinde`. Do not modify. #[derive(Debug)] -pub struct CreateParams { +pub struct CreateParams { pub id: uuid::Uuid, pub user_id: uuid::Uuid, pub device_name: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub mfa_verified: bool, + pub origin: T2, + pub impersonated_by: Option, } #[derive(Debug)] pub struct UpdateParams { @@ -29,6 +31,8 @@ pub struct Session { pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub mfa_verified: bool, + pub origin: String, + pub impersonated_by: Option, } pub struct SessionBorrowed<'a> { pub id: uuid::Uuid, @@ -37,6 +41,8 @@ pub struct SessionBorrowed<'a> { pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub mfa_verified: bool, + pub origin: &'a str, + pub impersonated_by: Option, } impl<'a> From> for Session { fn from( @@ -47,6 +53,8 @@ impl<'a> From> for Session { created_at, updated_at, mfa_verified, + origin, + impersonated_by, }: SessionBorrowed<'a>, ) -> Self { Self { @@ -56,6 +64,8 @@ impl<'a> From> for Session { created_at, updated_at, mfa_verified, + origin: origin.into(), + impersonated_by, } } } @@ -220,6 +230,8 @@ impl GetStmt { created_at: row.try_get(3)?, updated_at: row.try_get(4)?, mfa_verified: row.try_get(5)?, + origin: row.try_get(6)?, + impersonated_by: row.try_get(7)?, }) }, mapper: |it| Session::from(it), @@ -260,6 +272,8 @@ impl GetByRefreshTokenHashStmt { created_at: row.try_get(3)?, updated_at: row.try_get(4)?, mfa_verified: row.try_get(5)?, + origin: row.try_get(6)?, + impersonated_by: row.try_get(7)?, }) }, mapper: |it| Session::from(it), @@ -300,6 +314,8 @@ impl GetByRefreshTokenHashForUpdateStmt { created_at: row.try_get(3)?, updated_at: row.try_get(4)?, mfa_verified: row.try_get(5)?, + origin: row.try_get(6)?, + impersonated_by: row.try_get(7)?, }) }, mapper: |it| Session::from(it), @@ -337,6 +353,8 @@ impl ListByUserStmt { created_at: row.try_get(3)?, updated_at: row.try_get(4)?, mfa_verified: row.try_get(5)?, + origin: row.try_get(6)?, + impersonated_by: row.try_get(7)?, }) }, mapper: |it| Session::from(it), @@ -346,7 +364,7 @@ impl ListByUserStmt { pub struct CreateStmt(&'static str, Option); pub fn create() -> CreateStmt { CreateStmt( - "insert into sessions (id, user_id, device_name, created_at, updated_at, mfa_verified) values ($1, $2, $3, $4, $5, $6)", + "insert into sessions (id, user_id, device_name, created_at, updated_at, mfa_verified, origin, impersonated_by) values ($1, $2, $3, $4, $5, $6, $7, $8)", None, ) } @@ -358,7 +376,7 @@ impl CreateStmt { self.1 = Some(client.prepare(self.0).await?); Ok(self) } - pub async fn bind<'c, 'a, 's, C: GenericClient, T1: crate::StringSql>( + pub async fn bind<'c, 'a, 's, C: GenericClient, T1: crate::StringSql, T2: crate::StringSql>( &'s self, client: &'c C, id: &'a uuid::Uuid, @@ -367,6 +385,8 @@ impl CreateStmt { created_at: &'a chrono::DateTime, updated_at: &'a chrono::DateTime, mfa_verified: &'a bool, + origin: &'a T2, + impersonated_by: &'a Option, ) -> Result { client .execute( @@ -378,17 +398,19 @@ impl CreateStmt { created_at, updated_at, mfa_verified, + origin, + impersonated_by, ], ) .await } } -impl<'a, C: GenericClient + Send + Sync, T1: crate::StringSql> +impl<'a, C: GenericClient + Send + Sync, T1: crate::StringSql, T2: crate::StringSql> crate::client::async_::Params< 'a, 'a, 'a, - CreateParams, + CreateParams, std::pin::Pin< Box> + Send + 'a>, >, @@ -398,7 +420,7 @@ impl<'a, C: GenericClient + Send + Sync, T1: crate::StringSql> fn params( &'a self, client: &'a C, - params: &'a CreateParams, + params: &'a CreateParams, ) -> std::pin::Pin< Box> + Send + 'a>, > { @@ -410,6 +432,8 @@ impl<'a, C: GenericClient + Send + Sync, T1: crate::StringSql> ¶ms.created_at, ¶ms.updated_at, ¶ms.mfa_verified, + ¶ms.origin, + ¶ms.impersonated_by, )) } } diff --git a/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql b/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql new file mode 100644 index 00000000..98095d38 --- /dev/null +++ b/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql @@ -0,0 +1,6 @@ +-- Prefer rolling back the application while keeping these columns: an older +-- backend leaves new sessions at 'legacy'. Removing them forgets which open +-- sessions an administrator started, and those count as the owner's again. +ALTER TABLE sessions + DROP COLUMN impersonated_by, + DROP COLUMN origin; diff --git a/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/up.sql b/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/up.sql new file mode 100644 index 00000000..af6554f3 --- /dev/null +++ b/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/up.sql @@ -0,0 +1,19 @@ +-- Who signed in to a session. Additive and instant: no session ends, and no row +-- is rewritten or updated, so the row triggers on sessions do not fire. +-- +-- Existing sessions never recorded their origin and become 'legacy'. The same +-- default applies to any insert that names no origin, for example by an older +-- backend after an application rollback. The backend treats 'legacy' sessions +-- conservatively: only those with a device name count as the owner's own +-- sign-in, because signing in to someone else's account has never set one. +ALTER TABLE sessions + ADD COLUMN origin text NOT NULL DEFAULT 'legacy' + CHECK (origin IN ('sign_in','impersonation','legacy')), + -- The administrator who signed in to the account. Their sessions in other + -- accounts end together with the administrator's own account. + ADD COLUMN impersonated_by uuid REFERENCES users(id) ON DELETE CASCADE, + ADD CONSTRAINT sessions_impersonated_by_origin + CHECK (impersonated_by IS NULL OR origin='impersonation'); + +CREATE INDEX sessions_impersonated_by_idx ON sessions (impersonated_by) + WHERE impersonated_by IS NOT NULL; diff --git a/academy_persistence/postgres/queries/session.sql b/academy_persistence/postgres/queries/session.sql index fb3b9d19..debf4dbf 100644 --- a/academy_persistence/postgres/queries/session.sql +++ b/academy_persistence/postgres/queries/session.sql @@ -1,4 +1,4 @@ ---: Session (device_name?) +--: Session (device_name?, impersonated_by?) --! get : Session select * from sessions where id=:id; @@ -19,9 +19,9 @@ select s.* from sessions s --! list_by_user : Session select * from sessions where user_id=:user_id; ---! create (device_name?) -insert into sessions (id, user_id, device_name, created_at, updated_at, mfa_verified) - values (:id, :user_id, :device_name, :created_at, :updated_at, :mfa_verified); +--! create (device_name?, impersonated_by?) +insert into sessions (id, user_id, device_name, created_at, updated_at, mfa_verified, origin, impersonated_by) + values (:id, :user_id, :device_name, :created_at, :updated_at, :mfa_verified, :origin, :impersonated_by); --! update (device_name?, updated_at?) update sessions diff --git a/academy_persistence/postgres/src/session.rs b/academy_persistence/postgres/src/session.rs index 34883976..5a6045d2 100644 --- a/academy_persistence/postgres/src/session.rs +++ b/academy_persistence/postgres/src/session.rs @@ -1,6 +1,6 @@ use academy_di::Build; use academy_models::{ - session::{Session, SessionId, SessionPatchRef, SessionRefreshTokenHash}, + session::{Session, SessionId, SessionOrigin, SessionPatchRef, SessionRefreshTokenHash}, user::UserId, }; use academy_persistence_contracts::session::SessionRepository; @@ -134,6 +134,8 @@ impl SessionRepository for PostgresSessionRepository { created_at: session.created_at.into(), updated_at: session.updated_at.into(), mfa_verified: session.mfa_verified, + origin: encode_origin(session.origin), + impersonated_by: session.origin.impersonated_by().map(|admin| *admin), }; queries::session::create() @@ -286,5 +288,28 @@ fn decode_session(value: queries::session::Session) -> anyhow::Result { created_at: value.created_at.into(), updated_at: value.updated_at.into(), mfa_verified: value.mfa_verified, + origin: decode_origin(&value.origin, value.impersonated_by)?, + }) +} + +fn encode_origin(origin: SessionOrigin) -> &'static str { + match origin { + SessionOrigin::SignIn => "sign_in", + SessionOrigin::Impersonation { .. } => "impersonation", + SessionOrigin::Legacy => "legacy", + } +} + +fn decode_origin( + origin: &str, + impersonated_by: Option, +) -> anyhow::Result { + Ok(match (origin, impersonated_by) { + ("sign_in", None) => SessionOrigin::SignIn, + ("impersonation", admin) => SessionOrigin::Impersonation { + admin: admin.map(Into::into), + }, + ("legacy", None) => SessionOrigin::Legacy, + _ => anyhow::bail!("Invalid session origin {origin:?}"), }) } diff --git a/academy_persistence/postgres/tests/common/mod.rs b/academy_persistence/postgres/tests/common/mod.rs index ff451712..ca604ed0 100644 --- a/academy_persistence/postgres/tests/common/mod.rs +++ b/academy_persistence/postgres/tests/common/mod.rs @@ -62,15 +62,57 @@ pub async fn apply_through(db: &Db, last: Option<&str>) -> Vec<&'static str> { pub async fn seed(db: &Db) { let mut txn = db.begin_transaction().await.unwrap(); - academy_demo::create( - &mut txn, - PostgresUserRepository, - PostgresSessionRepository, - PostgresMfaRepository, - PostgresOAuth2Repository, - ) - .await - .unwrap(); + let origin_recorded: bool = txn + .txn() + .query_one( + "SELECT EXISTS(SELECT 1 FROM information_schema.columns \ + WHERE table_schema=current_schema() AND table_name='sessions' AND column_name='origin')", + &[], + ) + .await + .unwrap() + .get(0); + if origin_recorded { + academy_demo::create( + &mut txn, + PostgresUserRepository, + PostgresSessionRepository, + PostgresMfaRepository, + PostgresOAuth2Repository, + ) + .await + .unwrap(); + } else { + // Historical boundaries precede recorded session origins. Their demo + // sessions keep the columns of that time and become legacy sessions + // once the origin migration runs, like real sessions did. + academy_demo::user::create(&mut txn, PostgresUserRepository) + .await + .unwrap(); + for session in &*academy_demo::session::ALL_SESSIONS { + txn.txn() + .execute( + "INSERT INTO sessions(id,user_id,device_name,created_at,updated_at,mfa_verified) \ + VALUES($1,$2,$3,$4,$5,$6)", + &[ + &*session.id, + &*session.user_id, + &session.device_name.as_deref(), + &session.created_at, + &session.updated_at, + &session.mfa_verified, + ], + ) + .await + .unwrap(); + } + academy_demo::mfa::create(&mut txn, PostgresMfaRepository) + .await + .unwrap(); + academy_demo::oauth2::create(&mut txn, PostgresOAuth2Repository) + .await + .unwrap(); + } txn.commit().await.unwrap(); } diff --git a/academy_persistence/postgres/tests/invoice_identity.rs b/academy_persistence/postgres/tests/invoice_identity.rs index ae885dff..429a04b3 100644 --- a/academy_persistence/postgres/tests/invoice_identity.rs +++ b/academy_persistence/postgres/tests/invoice_identity.rs @@ -142,7 +142,8 @@ async fn invoice_identity_forward_controls() { "2026-09-12-170000_internal_heart_operations", "2026-09-26-120000_daily_learning_heart_receipts", "2026-10-03-140000_profile_publications", - "2026-10-04-070000_durable_totp_steps" + "2026-10-04-070000_durable_totp_steps", + "2026-10-04-175659_session_origin" ] ); } @@ -552,7 +553,8 @@ async fn pending_invoice_rich_export_controls() { "2026-09-12-170000_internal_heart_operations", "2026-09-26-120000_daily_learning_heart_receipts", "2026-10-03-140000_profile_publications", - "2026-10-04-070000_durable_totp_steps" + "2026-10-04-070000_durable_totp_steps", + "2026-10-04-175659_session_origin" ] ); } @@ -705,7 +707,8 @@ async fn erased_owner_identity_conflict_controls() { "2026-09-12-170000_internal_heart_operations", "2026-09-26-120000_daily_learning_heart_receipts", "2026-10-03-140000_profile_publications", - "2026-10-04-070000_durable_totp_steps" + "2026-10-04-070000_durable_totp_steps", + "2026-10-04-175659_session_origin" ] ); } diff --git a/academy_persistence/postgres/tests/migrations.rs b/academy_persistence/postgres/tests/migrations.rs index 72d73b8a..8051b0c9 100644 --- a/academy_persistence/postgres/tests/migrations.rs +++ b/academy_persistence/postgres/tests/migrations.rs @@ -13,7 +13,7 @@ async fn durable_totp_migration_preserves_devices_and_can_be_reverted() { "SELECT jsonb_agg(jsonb_build_array(id,encode(secret,'hex')) ORDER BY id)::text FROM totp_device_secrets", &[] ).await.unwrap().get(0); txn.commit().await.unwrap(); - assert_eq!(db.run_migrations(None).await.unwrap(), [NAME]); + assert_eq!(db.run_migrations(Some(1)).await.unwrap(), [NAME]); let txn = db.begin_transaction().await.unwrap(); let count: i64 = txn .txn() @@ -33,7 +33,92 @@ async fn durable_totp_migration_preserves_devices_and_can_be_reverted() { ).await.unwrap().get(0); assert_eq!(after, before); txn.commit().await.unwrap(); - assert_eq!(db.run_migrations(None).await.unwrap(), [NAME]); + assert_eq!(db.run_migrations(Some(1)).await.unwrap(), [NAME]); +} + +/// Recording who signed in must not sign anybody out. Existing sessions keep +/// every value and their refresh tokens, and become legacy sessions; so does a +/// session an older backend creates without naming an origin. +#[tokio::test] +async fn session_origin_migration_keeps_sessions_and_marks_them_legacy() { + use academy_models::session::SessionOrigin; + use academy_persistence_contracts::session::SessionRepository; + use academy_persistence_postgres::session::PostgresSessionRepository; + + const NAME: &str = "2026-10-04-175659_session_origin"; + const SESSIONS: &str = "SELECT jsonb_agg(jsonb_build_array(s.id,s.user_id,s.device_name,s.created_at,\ + s.updated_at,s.mfa_verified,encode(t.refresh_token_hash,'hex')) ORDER BY s.id)::text \ + FROM sessions s LEFT JOIN session_refresh_tokens t ON t.session_id=s.id"; + let db = common::setup_before(NAME, true).await; + let txn = db.begin_transaction().await.unwrap(); + txn.txn() + .execute( + "INSERT INTO session_refresh_tokens(session_id,refresh_token_hash) SELECT id,sha256(id::text::bytea) FROM sessions", + &[], + ) + .await + .unwrap(); + let before: String = txn.txn().query_one(SESSIONS, &[]).await.unwrap().get(0); + txn.commit().await.unwrap(); + + assert_eq!(db.run_migrations(Some(1)).await.unwrap(), [NAME]); + + let mut txn = db.begin_transaction().await.unwrap(); + let after: String = txn.txn().query_one(SESSIONS, &[]).await.unwrap().get(0); + assert_eq!(after, before); + for &demo in &*academy_demo::session::ALL_SESSIONS { + let session = PostgresSessionRepository + .get(&mut txn, demo.id) + .await + .unwrap() + .unwrap(); + assert_eq!(session.origin, SessionOrigin::Legacy); + assert_eq!(session.is_owner_sign_in(), demo.device_name.is_some()); + } + let (user, created): (uuid::Uuid, chrono::DateTime) = { + let demo = &academy_demo::session::FOO_1; + (*demo.user_id, demo.created_at) + }; + let older_backend: String = txn + .txn() + .query_one( + "INSERT INTO sessions(id,user_id,created_at,updated_at,mfa_verified) \ + VALUES(gen_random_uuid(),$1,$2,$2,false) RETURNING origin", + &[&user, &created], + ) + .await + .unwrap() + .get(0); + assert_eq!(older_backend, "legacy"); + txn.rollback().await.unwrap(); + + for statement in [ + "INSERT INTO sessions(id,user_id,created_at,updated_at,mfa_verified,origin) \ + VALUES(gen_random_uuid(),$1,$2,$2,false,'owner')", + "INSERT INTO sessions(id,user_id,created_at,updated_at,mfa_verified,origin,impersonated_by) \ + VALUES(gen_random_uuid(),$1,$2,$2,false,'sign_in',$1)", + "INSERT INTO sessions(id,user_id,created_at,updated_at,mfa_verified,origin,impersonated_by) \ + VALUES(gen_random_uuid(),$1,$2,$2,false,'impersonation',gen_random_uuid())", + ] { + let txn = db.begin_transaction().await.unwrap(); + let error = txn + .txn() + .execute(statement, &[&user, &created]) + .await + .unwrap_err(); + assert!( + matches!(error.code().unwrap().code(), "23514" | "23503"), + "{statement}: {error:?}" + ); + txn.rollback().await.unwrap(); + } + + assert_eq!(db.revert_migrations(Some(1)).await.unwrap(), [NAME]); + let txn = db.begin_transaction().await.unwrap(); + let reverted: String = txn.txn().query_one(SESSIONS, &[]).await.unwrap().get(0); + assert_eq!(reverted, before); + txn.commit().await.unwrap(); + assert_eq!(db.run_migrations(Some(1)).await.unwrap(), [NAME]); } async fn fingerprint(db: &common::Db) -> std::collections::BTreeMap { diff --git a/academy_persistence/postgres/tests/repos/session.rs b/academy_persistence/postgres/tests/repos/session.rs index 548528d0..23de05aa 100644 --- a/academy_persistence/postgres/tests/repos/session.rs +++ b/academy_persistence/postgres/tests/repos/session.rs @@ -1,12 +1,14 @@ use std::time::Duration; use academy_demo::{ - SHA256HASH1, SHA256HASH2, UUID1, + SHA256HASH1, SHA256HASH2, UUID1, UUID2, session::{ADMIN_1, ALL_SESSIONS, FOO_1, FOO_2}, user::{ADMIN, ALL_USERS, FOO}, }; -use academy_models::session::{Session, SessionRefreshTokenHash}; -use academy_persistence_contracts::{Database, Transaction, session::SessionRepository}; +use academy_models::session::{Session, SessionOrigin, SessionRefreshTokenHash}; +use academy_persistence_contracts::{ + Database, Transaction, session::SessionRepository, user::UserRepository, +}; use academy_persistence_postgres::session::PostgresSessionRepository; use academy_utils::patch::Patch; use pretty_assertions::assert_eq; @@ -85,6 +87,7 @@ async fn create() { created_at: ADMIN.user.created_at + Duration::from_secs(10 * 3600), updated_at: ADMIN.user.created_at + Duration::from_secs(7 * 24 * 3600), mfa_verified: true, + origin: SessionOrigin::SignIn, }; let mut txn = db.begin_transaction().await.unwrap(); @@ -98,6 +101,71 @@ async fn create() { ); } +/// The origin is stored once and read back unchanged, including who signed in +/// to someone else's account. +#[tokio::test] +async fn create_impersonation() { + let db = setup().await; + + for (id, admin) in [(UUID1, Some(ADMIN.user.id)), (UUID2, None)] { + let session = Session { + id: id.into(), + device_name: None, + mfa_verified: false, + origin: SessionOrigin::Impersonation { admin }, + ..FOO_1.clone() + }; + + let mut txn = db.begin_transaction().await.unwrap(); + REPO.create(&mut txn, &session).await.unwrap(); + txn.commit().await.unwrap(); + + let mut txn = db.begin_transaction().await.unwrap(); + let result = REPO.get(&mut txn, session.id).await.unwrap().unwrap(); + assert_eq!(result, session); + assert!(!result.is_owner_sign_in()); + } +} + +/// Sessions an administrator opened in other accounts end together with the +/// administrator's own account. +#[tokio::test] +async fn impersonation_sessions_end_with_the_admin_account() { + let db = setup().await; + let session = Session { + id: UUID1.into(), + device_name: None, + mfa_verified: false, + origin: SessionOrigin::Impersonation { + admin: Some(ADMIN.user.id), + }, + ..FOO_1.clone() + }; + + let mut txn = db.begin_transaction().await.unwrap(); + REPO.create(&mut txn, &session).await.unwrap(); + REPO.save_refresh_token_hash(&mut txn, session.id, (*SHA256HASH1).into()) + .await + .unwrap(); + txn.commit().await.unwrap(); + + let mut txn = db.begin_transaction().await.unwrap(); + assert!( + academy_persistence_postgres::user::PostgresUserRepository + .delete(&mut txn, ADMIN.user.id) + .await + .unwrap() + ); + txn.commit().await.unwrap(); + + let mut txn = db.begin_transaction().await.unwrap(); + assert_eq!(REPO.get(&mut txn, session.id).await.unwrap(), None); + assert_eq!( + REPO.get(&mut txn, FOO_1.id).await.unwrap().as_ref(), + Some(&*FOO_1) + ); +} + #[tokio::test] async fn update() { let db = setup().await; diff --git a/tests/publication.md b/tests/publication.md index 774b0109..8ea37522 100644 --- a/tests/publication.md +++ b/tests/publication.md @@ -36,6 +36,18 @@ user ID, display name, standard-avatar null and visibility revision; XP and rankings stay in their owning services. Error and success responses prevent HTTP storage. No public profile route or UI activation is included in this step. +Sessions record who signed in (`sign_in`, `impersonation` with the +administrator, or `legacy` for sessions from before origins were recorded). +Only the owner's own sign-in can share, make private again or confirm the +notice, on the publication route and through the old `leaderboard_opt_out` +flag. A session an administrator opened in the account is refused with +`owner_sign_in_required` right after it was issued, after a refresh and after a +backend restart, and every refused attempt lands in the administrative audit +log for that administrator. Legacy sessions with a device name act as the +owner's sign-in; without one they are refused like an impersonation, since +signing in to someone else's account has never set a device name. Support +keeps withdrawing through its own route. + PostgreSQL repository tests additionally cover old/import/demo defaults, schema-only rollback/reapply, incomplete receipts and transactional rollback. The existing migration preservation tests name their intended guard, allowing diff --git a/tests/publication.py b/tests/publication.py index 1e4386d0..dc883b7e 100644 --- a/tests/publication.py +++ b/tests/publication.py @@ -631,12 +631,132 @@ def support_queued_requests_recheck_current_authority(f): assert f.sql(f"SELECT row_to_json(p)::text FROM user_profiles p WHERE user_id='{owner['id']}'") == before +REFUSED = {"detail": "owner_sign_in_required"} +PROFILE_ROW = "SELECT row_to_json(p)::text FROM user_profiles p WHERE user_id='{}'" + + +def impersonate(f, admin, owner): + login = f.request(f"/auth/sessions/{owner['id']}", "POST", token=f.token(admin)) + assert login["user"]["id"] == owner["id"] + assert login["session"]["device_name"] is None and login["session"]["mfa_verified"] is False + stored = f.sql(f"SELECT origin||'|'||impersonated_by FROM sessions WHERE id='{login['session']['id']}'") + assert stored == f"impersonation|{admin['id']}", stored + return login + + +def refresh(f, login): + renewed = f.request("/auth/session", "PUT", {"refresh_token": login["refresh_token"]}) + assert renewed["session"]["id"] == login["session"]["id"] + return {**login, **renewed} + + +def withdrawal(revision): + return {"profile_visibility": "private", "expected_revision": revision, "request_id": str(uuid4())} + + +def impersonation_cannot_choose_even_after_refresh(f): + if not f.publication_enabled: + f.activate() + owner, admin = f.account(), support_admin(f) + audit = ( + f"SELECT count(*) FROM admin_audit_log WHERE admin_user_id='{admin['id']}' " + f"AND target_user_id='{owner['id']}' AND method='PUT' AND path='{OWNER}' AND status=403" + ) + session = impersonate(f, admin, owner) + before = f.sql(PROFILE_ROW.format(owner["id"])) + # Troubleshooting may still read the choice and the preview. + revision = f.publication(token=f.token(session))["visibility_revision"] + preview = f.publication(OWNER + "-preview", token=f.token(session)) + share = { + "profile_visibility": "shared", + "expected_revision": revision, + "scope_version": preview["scope_version"], + "notice_hash": preview["notice_hash"], + "preview_token": preview["preview_token"], + } + attempts = 0 + for step in ("issued", "refreshed", "restarted"): + for body in (share, withdrawal(revision)): + result = f.publication( + method="PUT", body={**body, "request_id": str(uuid4())}, token=f.token(session), expected=403 + ) + assert result == REFUSED, (step, result) + attempts += 1 + # The old opt-out flag shares or withdraws as well and is refused alike. + f.request("/auth/users/me", "PATCH", {"leaderboard_opt_out": False}, token=f.token(session), expected=403) + if step == "refreshed": + f.restart() + session = refresh(f, session) + assert f.sql(PROFILE_ROW.format(owner["id"])) == before + assert f.sql(f"SELECT origin FROM sessions WHERE id='{session['session']['id']}'") == "impersonation" + assert owner["id"] not in [p["user_id"] for p in f.publication(ROOT + "snapshot", token=f.auth)["participants"]] + # Every refused attempt is recorded for the administrator, acting on the account. + assert int(f.sql(audit)) == attempts + + # The owner's own sign-in shares, also after a refresh, recorded as the owner's. + owner = refresh(f, owner) + _, own = f.preview_choice(owner) + shared = f.publication(method="PUT", body=own, token=f.token(owner)) + assert shared["receipt"]["source"] == "owner" + revision = shared["current"]["visibility_revision"] + # The administrator's session cannot withdraw it either. + assert f.publication(method="PUT", body=withdrawal(revision), token=f.token(session), expected=403) == REFUSED + f.request("/auth/users/me", "PATCH", {"leaderboard_opt_out": True}, token=f.token(session), expected=403) + assert f.publication(token=f.token(owner))["profile_visibility"] == "shared" + assert int(f.sql(audit)) == attempts + 1 + # Support withdraws through its own route, recorded as support. + withdrawn = f.publication( + f"/auth/admin/users/{owner['id']}/publication/withdraw", + method="POST", + body={"expected_revision": revision, "request_id": str(uuid4())}, + token=f.token(admin), + ) + assert withdrawn["receipt"]["source"] == "support" + # The owner's own sign-in can withdraw too. + _, again = f.preview_choice(owner) + revision = f.publication(method="PUT", body=again, token=f.token(owner))["current"]["visibility_revision"] + private = f.publication(method="PUT", body=withdrawal(revision), token=f.token(owner)) + assert private["current"]["profile_visibility"] == "private" and private["receipt"]["source"] == "owner" + + +def legacy_sessions_follow_their_device_name(f): + if not f.publication_enabled: + f.activate() + owner, admin = f.account(), support_admin(f) + # The fixture signs in with a User-Agent, like every browser. + assert owner["session"]["device_name"] + session = impersonate(f, admin, owner) + ids = f"'{owner['session']['id']}','{session['session']['id']}'" + # Sessions from before the migration carry no recorded origin. + f.sql(f"UPDATE sessions SET origin='legacy', impersonated_by=NULL WHERE id IN ({ids})") + owner, session = refresh(f, owner), refresh(f, session) + assert f.sql(f"SELECT string_agg(origin, ',') FROM sessions WHERE id IN ({ids})") == "legacy,legacy" + before = f.sql(PROFILE_ROW.format(owner["id"])) + # Without a device name it may have been opened by an administrator. + revision = f.publication(token=f.token(session))["visibility_revision"] + _, share = f.preview_choice(session) + assert f.publication(method="PUT", body=share, token=f.token(session), expected=403) == REFUSED + assert f.publication(method="PUT", body=withdrawal(revision), token=f.token(session), expected=403) == REFUSED + f.request("/auth/users/me", "PATCH", {"leaderboard_opt_out": False}, token=f.token(session), expected=403) + assert f.sql(PROFILE_ROW.format(owner["id"])) == before + # With a device name it is the owner's own sign-in. + _, share = f.preview_choice(owner) + shared = f.publication(method="PUT", body=share, token=f.token(owner)) + assert shared["receipt"]["source"] == "owner" + private = f.publication( + method="PUT", body=withdrawal(shared["current"]["visibility_revision"]), token=f.token(owner) + ) + assert private["current"]["profile_visibility"] == "private" + + CASES = [ disabled_contract_and_service_auth, owner_preview_and_exact_projection, race_replay_and_legacy_clients, support_permissions_withdrawal_and_replay, support_queued_requests_recheck_current_authority, + impersonation_cannot_choose_even_after_refresh, + legacy_sessions_follow_their_device_name, reset_preserves_consent_and_revokes_publication_authority, queued_publication_cannot_gain_authority_after_reset, current_verification_withdrawal_and_export, From 9e909190c21d38b1537a870caa3bf5f6dbb0b14f Mon Sep 17 00:00:00 2001 From: The Morpheus Tutorials <22853222+TheMorpheus407@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:15:02 +0200 Subject: [PATCH 2/2] Preserve impersonation provenance and audit across authentication paths Block delegated credential and visibility changes, retain the original operator on descendant sessions, and capture audit actors before requests mutate their sessions. Cover API and native CLI regressions, refresh without a bearer, concurrent revocation, and safe schema rollback. --- .github/workflows/test.yml | 13 + academy/src/environment/types.rs | 4 +- academy_api/rest/src/errors.rs | 6 + .../rest/src/middlewares/admin_audit.rs | 81 ++++- academy_api/rest/src/models/admin_audit.rs | 3 +- academy_api/rest/src/routes/publication.rs | 13 + academy_api/rest/src/routes/session.rs | 4 +- academy_core/admin_audit/contracts/src/lib.rs | 41 ++- academy_core/admin_audit/impl/src/lib.rs | 148 ++++++-- .../admin_audit/impl/src/tests/mod.rs | 13 +- .../admin_audit/impl/src/tests/record.rs | 310 +++++++--------- academy_core/mfa/impl/src/lib.rs | 25 +- academy_core/mfa/impl/src/tests/disable.rs | 29 ++ academy_core/mfa/impl/src/tests/enable.rs | 36 ++ academy_core/mfa/impl/src/tests/initialize.rs | 29 ++ academy_core/mfa/impl/src/tests/mod.rs | 4 +- academy_core/oauth2/impl/src/lib.rs | 12 +- .../oauth2/impl/src/tests/create_link.rs | 57 +++ .../oauth2/impl/src/tests/delete_link.rs | 29 ++ academy_core/oauth2/impl/src/tests/mod.rs | 4 +- academy_core/session/impl/src/lib.rs | 25 +- .../session/impl/src/tests/impersonate.rs | 2 + academy_core/user/impl/src/lib.rs | 14 +- academy_core/user/impl/src/publication.rs | 6 + .../user/impl/src/tests/update_user/email.rs | 50 +++ .../impl/src/tests/update_user/password.rs | 22 ++ academy_models/src/admin_audit.rs | 3 +- academy_models/src/auth.rs | 2 + academy_persistence/contracts/src/session.rs | 20 + .../clorinde/src/queries/admin_audit.rs | 8 +- .../2026-10-04-175659_session_origin/down.sql | 9 + .../2026-10-05-180000_operator_audit/down.sql | 3 + .../2026-10-05-180000_operator_audit/up.sql | 3 + .../postgres/queries/admin_audit.sql | 4 +- .../postgres/src/admin_audit.rs | 4 +- .../postgres/tests/invoice_identity.rs | 9 +- .../postgres/tests/migrations.rs | 80 ++++ .../postgres/tests/repos/admin_audit.rs | 38 +- tests/impersonation.md | 32 ++ tests/impersonation.py | 344 ++++++++++++++++++ 40 files changed, 1267 insertions(+), 272 deletions(-) create mode 100644 academy_persistence/postgres/migrations/2026-10-05-180000_operator_audit/down.sql create mode 100644 academy_persistence/postgres/migrations/2026-10-05-180000_operator_audit/up.sql create mode 100644 tests/impersonation.md create mode 100644 tests/impersonation.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00c10554..4ffffd72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -103,6 +103,19 @@ jobs: --pg-bin "$(dirname "$(command -v pg_ctl)")" \ --valkey "$(command -v valkey-server)" \ --output "$RUNNER_TEMP/academy-sensitive-writes-test-evidence"' + - name: test impersonation provenance and durable request audit through HTTP and CLI + run: | + nix shell --accept-flake-config --no-write-lock-file --inputs-from . \ + nixpkgs#postgresql_18 nixpkgs#valkey -c bash -euo pipefail -c \ + 'python3 -B tests/impersonation.py --binary target/debug/academy \ + --pg-bin "$(dirname "$(command -v pg_ctl)")" \ + --valkey "$(command -v valkey-server)" \ + --output "$RUNNER_TEMP/academy-impersonation-test-evidence"' + - uses: actions/upload-artifact@v4 + if: always() + with: + name: impersonation-test-evidence + path: ${{ runner.temp }}/academy-impersonation-test-evidence - uses: actions/upload-artifact@v4 if: always() with: diff --git a/academy/src/environment/types.rs b/academy/src/environment/types.rs index 90cddaf6..cbf1dedf 100644 --- a/academy/src/environment/types.rs +++ b/academy/src/environment/types.rs @@ -226,6 +226,7 @@ pub type MfaFeature = MfaFeatureServiceImpl< MfaRecovery, MfaDisable, MfaTotpDevice, + SessionRepo, >; pub type MfaRecovery = MfaRecoveryServiceImpl; pub type MfaAuthenticate = MfaAuthenticateServiceImpl; @@ -242,6 +243,7 @@ pub type OAuth2Feature = OAuth2FeatureServiceImpl< OAuth2Login, OAuth2Registration, Session, + SessionRepo, >; pub type OAuth2Link = OAuth2LinkServiceImpl; pub type OAuth2Authorization = OAuth2AuthorizationServiceImpl; @@ -316,7 +318,7 @@ pub type Internal = InternalServiceImpl< >; pub type AdminAuditFeature = - AdminAuditFeatureServiceImpl; + AdminAuditFeatureServiceImpl; pub type PurchaseRepo = academy_persistence_postgres::purchase::PostgresPurchaseRepository; pub type PurchaseFeature = academy_core_purchase_impl::PurchaseFeatureServiceImpl< diff --git a/academy_api/rest/src/errors.rs b/academy_api/rest/src/errors.rs index f470e6f6..a220612a 100644 --- a/academy_api/rest/src/errors.rs +++ b/academy_api/rest/src/errors.rs @@ -26,6 +26,9 @@ pub fn auth_error(err: AuthError) -> Response { InvalidTokenError.into_response() } AuthError::Authenticate(AuthenticateError::Other(err)) => internal_server_error(err), + AuthError::Authorize(AuthorizeError::OwnerSignIn) => { + OwnerSignInRequiredError.into_response() + } AuthError::Authorize(AuthorizeError::Admin) => PermissionDeniedError.into_response(), AuthError::Authorize(AuthorizeError::AdminMfa) => AdminMfaRequiredError.into_response(), AuthError::Authorize(AuthorizeError::EmailVerified) => { @@ -39,6 +42,7 @@ pub fn auth_error_docs(op: TransformOperation) -> TransformOperation { .with(internal_server_error_docs) .add_error::() .add_error::() + .add_error::() .add_error::() } @@ -66,6 +70,8 @@ error_code! { /// authenticated with a second factor. Log in again and provide the TOTP /// code, or set up two factor authentication first. pub AdminMfaRequiredError(FORBIDDEN, "Admin MFA required"); + /// Only an owner sign-in can change credentials or profile visibility. + pub OwnerSignInRequiredError(FORBIDDEN, "Owner sign-in required"); /// The authenticated user has not verified their email address. EmailNotVerifiedError(FORBIDDEN, "Email not verified"); diff --git a/academy_api/rest/src/middlewares/admin_audit.rs b/academy_api/rest/src/middlewares/admin_audit.rs index ac74e22a..3313d240 100644 --- a/academy_api/rest/src/middlewares/admin_audit.rs +++ b/academy_api/rest/src/middlewares/admin_audit.rs @@ -5,17 +5,20 @@ use std::sync::Arc; -use academy_core_admin_audit_contracts::{AdminAuditFeatureService, AdminAuditRequest}; +use academy_core_admin_audit_contracts::{ + AdminAuditCapture, AdminAuditCredential, AdminAuditFeatureService, AdminAuditRequest, +}; use academy_models::{ admin_audit::{RequestId as AuditRequestId, RequestMethod, RequestPath}, auth::AccessToken, }; use aide::axum::ApiRouter; use axum::{ + body::{Body, to_bytes}, extract::{MatchedPath, Request}, - http::{Method, header::AUTHORIZATION}, + http::{Method, StatusCode, header::AUTHORIZATION}, middleware::{Next, from_fn}, - response::Response, + response::{IntoResponse, Response}, }; use tracing::error; @@ -60,10 +63,6 @@ async fn middleware( return next.run(request).await; } - let Some(token) = access_token(&request) else { - return next.run(request).await; - }; - let method = RequestMethod::from_string_truncated(request.method().to_string()); // Query values and finance path bearers must never reach audit storage or // its instrumented services, including rejected methods and unknown routes. @@ -77,7 +76,71 @@ async fn middleware( .get::() .map(|request_id| AuditRequestId::from_string_truncated(request_id.to_string())); - let response = next.run(request).await; + let mut rejected = None; + let (request, captured) = + if request.method() == Method::PUT && request.uri().path() == "/auth/session" { + let (parts, body) = request.into_parts(); + let bytes = match to_bytes(body, 2 * 1024 * 1024).await { + Ok(bytes) => bytes, + Err(_) => { + rejected = Some(StatusCode::PAYLOAD_TOO_LARGE.into_response()); + Default::default() + } + }; + // Use the actual refresh credential even if an unrelated/expired + // bearer was supplied; it is the refresh credential that acts here. + let mut captured = + match serde_json::from_slice::(&bytes) { + Ok(body) => { + service + .capture(AdminAuditCredential::Refresh(&body.refresh_token)) + .await + } + Err(_) => Ok(AdminAuditCapture::InvalidCredential), + }; + let request = Request::from_parts(parts, Body::from(bytes)); + // Rejected refresh requests still retain a supplied administrative + // bearer. A valid ordinary refresh credential must take precedence. + if matches!(captured, Ok(AdminAuditCapture::InvalidCredential)) + && let Some(token) = access_token(&request) + { + captured = service.capture(AdminAuditCredential::Access(&token)).await; + } + (request, captured) + } else { + let captured = match access_token(&request) { + Some(token) => service.capture(AdminAuditCredential::Access(&token)).await, + None => Ok(AdminAuditCapture::InvalidCredential), + }; + (request, captured) + }; + let actor = match captured { + Ok(AdminAuditCapture::Recorded(actor)) => Some(actor), + Ok(AdminAuditCapture::Unrecorded | AdminAuditCapture::InvalidCredential) => None, + Err(err) => { + error!("failed to capture administrative audit attribution: {err:#}"); + if route.as_deref().is_some_and(|route| { + matches!( + route.as_str(), + "/auth/users/me/publication" + | "/auth/admin/users/{user_id}/publication" + | "/auth/admin/users/{user_id}/publication/withdraw" + ) + }) { + return crate::routes::publication::audit_unavailable(err); + } + return crate::errors::internal_server_error(err); + } + }; + + let response = match rejected { + Some(response) => response, + None => next.run(request).await, + }; + + let Some(actor) = actor else { + return response; + }; // Without a request id the entry could not be tied back to the logs, and // its absence means the request id middleware is missing. @@ -88,7 +151,7 @@ async fn middleware( if let Err(err) = service .record(AdminAuditRequest { - token, + actor, method, path, route, diff --git a/academy_api/rest/src/models/admin_audit.rs b/academy_api/rest/src/models/admin_audit.rs index 93ef1622..e8ca25fe 100644 --- a/academy_api/rest/src/models/admin_audit.rs +++ b/academy_api/rest/src/models/admin_audit.rs @@ -17,7 +17,8 @@ pub struct ApiAdminAuditLogEntry { /// Time at which the request was answered pub at: ApiTimestamp, /// The administrator whose access token authenticated the request - pub admin_user_id: UserId, + /// None identifies a server CLI operator with no known admin account. + pub admin_user_id: Option, /// HTTP method of the request pub method: RequestMethod, /// Path of the request, without the query string diff --git a/academy_api/rest/src/routes/publication.rs b/academy_api/rest/src/routes/publication.rs index 167faccd..115aed32 100644 --- a/academy_api/rest/src/routes/publication.rs +++ b/academy_api/rest/src/routes/publication.rs @@ -71,6 +71,19 @@ async fn no_store(request: Request, next: Next) -> Response { response } +/// Audit preparation is also part of the publication authority check. Keep +/// the same unavailable contract and storage headers when it fails first. +pub(crate) fn audit_unavailable(error: anyhow::Error) -> Response { + let mut response = respond::<()>(Err(PublicationError::Other(error))); + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store")); + response + .headers_mut() + .append(VARY, HeaderValue::from_static("Authorization")); + response +} + fn respond(result: Result) -> Response { let error = match result { Ok(value) => return Json(value).into_response(), diff --git a/academy_api/rest/src/routes/session.rs b/academy_api/rest/src/routes/session.rs index 6e86cc67..ed078807 100644 --- a/academy_api/rest/src/routes/session.rs +++ b/academy_api/rest/src/routes/session.rs @@ -222,8 +222,8 @@ fn impersonate_docs(op: TransformOperation) -> TransformOperation { } #[derive(Deserialize, JsonSchema)] -struct RefreshRequest { - refresh_token: RefreshToken, +pub(crate) struct RefreshRequest { + pub(crate) refresh_token: RefreshToken, } async fn refresh( diff --git a/academy_core/admin_audit/contracts/src/lib.rs b/academy_core/admin_audit/contracts/src/lib.rs index f53e85c1..f378d907 100644 --- a/academy_core/admin_audit/contracts/src/lib.rs +++ b/academy_core/admin_audit/contracts/src/lib.rs @@ -2,18 +2,24 @@ use std::future::Future; use academy_models::{ admin_audit::{AdminAuditLogEntry, AdminAuditLogFilter, RequestId, RequestMethod, RequestPath}, - auth::{AccessToken, AuthError}, + auth::{AccessToken, AuthError, RefreshToken}, pagination::PaginationSlice, user::UserId, }; use thiserror::Error; pub trait AdminAuditFeatureService: Send + Sync + 'static { + /// Capture durable attribution before the handler can rotate or revoke it. + fn capture( + &self, + credential: AdminAuditCredential<'_>, + ) -> impl Future> + Send; + /// Record a request in the administrative audit log. /// /// Requests made in a session an administrator opened in someone else's - /// account are recorded for that administrator. Other requests that were - /// not authenticated with an administrator's access token are ignored. + /// account are recorded for that administrator. Attribution has already + /// been captured; recording never authenticates the credential again. /// Returns whether an entry has been recorded. fn record( &self, @@ -30,11 +36,36 @@ pub trait AdminAuditFeatureService: Send + Sync + 'static { ) -> impl Future> + Send; } +/// A credential used only before a handler runs; never persisted in the audit. +pub enum AdminAuditCredential<'a> { + Access(&'a AccessToken), + Refresh(&'a RefreshToken), +} + +/// Distinguish an invalid credential from a valid ordinary owner's credential. +/// An unrelated bearer must never replace a valid refresh actor, even when the +/// latter does not belong in the administrative audit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdminAuditCapture { + InvalidCredential, + Unrecorded, + Recorded(AdminAuditActor), +} + +/// Server-side attribution retained independently of the session's lifetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdminAuditActor { + pub user_id: UserId, + /// None for an operator using the server CLI, whose identity is unknown. + pub admin_user_id: Option, + pub impersonated: bool, +} + /// A request that has been answered and may need to be recorded. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AdminAuditRequest { - /// The access token the request was authenticated with - pub token: AccessToken, + /// Trusted attribution captured before the request handler + pub actor: AdminAuditActor, /// HTTP method of the request pub method: RequestMethod, /// Path of the request, without the query string diff --git a/academy_core/admin_audit/impl/src/lib.rs b/academy_core/admin_audit/impl/src/lib.rs index 583682c1..3061914a 100644 --- a/academy_core/admin_audit/impl/src/lib.rs +++ b/academy_core/admin_audit/impl/src/lib.rs @@ -1,12 +1,18 @@ -use academy_auth_contracts::{AuthResultExt, AuthService}; +use academy_auth_contracts::{AuthResultExt, AuthService, AuthenticateByRefreshTokenError}; use academy_core_admin_audit_contracts::{ - AdminAuditFeatureService, AdminAuditListError, AdminAuditListQuery, AdminAuditListResult, - AdminAuditRequest, target_user_id, + AdminAuditActor, AdminAuditCapture, AdminAuditCredential, AdminAuditFeatureService, + AdminAuditListError, AdminAuditListQuery, AdminAuditListResult, AdminAuditRequest, + target_user_id, }; use academy_di::Build; -use academy_models::{admin_audit::AdminAuditLogEntry, auth::AccessToken}; +use academy_models::{ + admin_audit::AdminAuditLogEntry, + auth::{AccessToken, AuthenticateError}, + session::SessionOrigin, +}; use academy_persistence_contracts::{ Database, Transaction, admin_audit::AdminAuditRepository, session::SessionRepository, + user::UserRepository, }; use academy_shared_contracts::{id::IdService, time::TimeService}; use academy_utils::trace_instrument; @@ -17,17 +23,18 @@ mod tests; #[derive(Debug, Clone, Build)] #[cfg_attr(test, derive(Default))] -pub struct AdminAuditFeatureServiceImpl { +pub struct AdminAuditFeatureServiceImpl { db: Db, auth: Auth, id: Id, time: Time, admin_audit_repo: AdminAuditRepo, session_repo: SessionRepo, + user_repo: UserRepo, } -impl AdminAuditFeatureService - for AdminAuditFeatureServiceImpl +impl AdminAuditFeatureService + for AdminAuditFeatureServiceImpl where Db: Database, Auth: AuthService, @@ -35,42 +42,113 @@ where Time: TimeService, AdminAuditRepo: AdminAuditRepository, SessionRepo: SessionRepository, + UserRepo: UserRepository, { - // The request carries the access token it was made with. - #[trace_instrument(skip(self, request), fields(path = %*request.path))] - async fn record(&self, request: AdminAuditRequest) -> anyhow::Result { - // An expired or invalidated token identifies nobody, so there is - // nothing to attribute the request to. - let Ok(auth) = self.auth.authenticate(&request.token).await else { - return Ok(false); + #[trace_instrument(skip_all)] + async fn capture( + &self, + credential: AdminAuditCredential<'_>, + ) -> anyhow::Result { + // The ordinary read authentication checks durable authority without + // taking a write lock. Run it before borrowing our transaction so a + // one-connection pool works too. Unrecorded owner requests must retain + // their handler's own lock/acceptance order. + let access_auth = if let AdminAuditCredential::Access(token) = credential { + match self.auth.authenticate(token).await { + Ok(auth) => Some(auth), + Err(AuthenticateError::InvalidToken) => { + return Ok(AdminAuditCapture::InvalidCredential); + } + Err(AuthenticateError::Other(error)) => return Err(error), + } + } else { + None }; - let mut txn = self.db.begin_transaction().await?; - - // A session an administrator opened in someone else's account carries - // that account's token. Its requests are the administrator's and act - // on that account. - let impersonated_by = self + let (session_id, admin) = match credential { + AdminAuditCredential::Access(token) => { + let auth = access_auth.expect("Access credential was authenticated"); + let observed_session = self.session_repo.get(&mut txn, auth.session_id).await?; + if !auth.admin + && !observed_session.is_some_and(|session| { + matches!(session.origin, SessionOrigin::Impersonation { .. }) + }) + { + txn.rollback().await?; + return Ok(AdminAuditCapture::Unrecorded); + } + // Recorded actors are checked again under the current account + // and session locks before their immutable origin is captured. + let auth = match self.auth.authenticate_in_transaction(&mut txn, token).await { + Ok(auth) => auth, + Err(AuthenticateError::InvalidToken) => { + return Ok(AdminAuditCapture::InvalidCredential); + } + Err(AuthenticateError::Other(error)) => return Err(error), + }; + (auth.session_id, auth.admin) + } + AdminAuditCredential::Refresh(token) => { + let id = match self + .auth + .authenticate_by_refresh_token(&mut txn, token) + .await + { + Ok(id) => id, + Err( + AuthenticateByRefreshTokenError::Invalid + | AuthenticateByRefreshTokenError::Expired(_), + ) => return Ok(AdminAuditCapture::InvalidCredential), + Err(AuthenticateByRefreshTokenError::Other(error)) => return Err(error), + }; + let session = self + .session_repo + .get(&mut txn, id) + .await? + .context("Authenticated refresh session disappeared")?; + let user = self + .user_repo + .get_composite(&mut txn, session.user_id) + .await? + .context("Authenticated refresh account disappeared")?; + (id, user.user.admin) + } + }; + let session = self .session_repo - .get(&mut txn, auth.session_id) - .await - .context("Failed to get session from database")? - .and_then(|session| session.origin.impersonated_by()); + .get(&mut txn, session_id) + .await? + .context("Authenticated audit session disappeared")?; + let actor = match session.origin { + SessionOrigin::Impersonation { admin } => { + AdminAuditCapture::Recorded(AdminAuditActor { + user_id: session.user_id, + admin_user_id: admin, + impersonated: true, + }) + } + _ if admin => AdminAuditCapture::Recorded(AdminAuditActor { + user_id: session.user_id, + admin_user_id: Some(session.user_id), + impersonated: false, + }), + _ => AdminAuditCapture::Unrecorded, + }; + // Release the read/authentication locks before the handler starts. + txn.rollback().await?; + Ok(actor) + } + #[trace_instrument(skip(self, request), fields(path = %*request.path))] + async fn record(&self, request: AdminAuditRequest) -> anyhow::Result { let target_user_id = target_user_id( &request.path, request.route.as_deref().map(String::as_str), - auth.user_id, - ); - - // The entry is written whenever the request was made with an - // administrator's token or in a session an administrator opened, - // including for requests that were rejected. - let (admin_user_id, target_user_id) = match impersonated_by { - Some(admin) => (admin, target_user_id.or(Some(auth.user_id))), - None if auth.admin => (auth.user_id, target_user_id), - None => return Ok(false), - }; + request.actor.user_id, + ) + .or_else(|| request.actor.impersonated.then_some(request.actor.user_id)); + let admin_user_id = request.actor.admin_user_id; + let mut txn = self.db.begin_transaction().await?; let entry = AdminAuditLogEntry { id: self.id.generate(), diff --git a/academy_core/admin_audit/impl/src/tests/mod.rs b/academy_core/admin_audit/impl/src/tests/mod.rs index 1af2d14f..9d7d9560 100644 --- a/academy_core/admin_audit/impl/src/tests/mod.rs +++ b/academy_core/admin_audit/impl/src/tests/mod.rs @@ -1,10 +1,10 @@ use academy_auth_contracts::MockAuthService; -use academy_core_admin_audit_contracts::AdminAuditRequest; +use academy_core_admin_audit_contracts::{AdminAuditActor, AdminAuditRequest}; use academy_demo::{UUID1, user::ADMIN}; use academy_models::admin_audit::AdminAuditLogEntry; use academy_persistence_contracts::{ MockDatabase, MockTransaction, admin_audit::MockAdminAuditRepository, - session::MockSessionRepository, + session::MockSessionRepository, user::MockUserRepository, }; use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; use chrono::{DateTime, TimeZone, Utc}; @@ -21,6 +21,7 @@ type Sut = AdminAuditFeatureServiceImpl< MockTimeService, MockAdminAuditRepository, MockSessionRepository, + MockUserRepository, >; fn now() -> DateTime { @@ -29,7 +30,11 @@ fn now() -> DateTime { fn make_request() -> AdminAuditRequest { AdminAuditRequest { - token: "token".into(), + actor: AdminAuditActor { + user_id: ADMIN.user.id, + admin_user_id: Some(ADMIN.user.id), + impersonated: false, + }, method: "PATCH".try_into().unwrap(), path: format!("/auth/users/{}", *ADMIN.user.id) .try_into() @@ -45,7 +50,7 @@ fn make_entry() -> AdminAuditLogEntry { AdminAuditLogEntry { id: UUID1.into(), at: now(), - admin_user_id: ADMIN.user.id, + admin_user_id: Some(ADMIN.user.id), method: request.method, path: request.path, target_user_id: Some(ADMIN.user.id), diff --git a/academy_core/admin_audit/impl/src/tests/record.rs b/academy_core/admin_audit/impl/src/tests/record.rs index 40f9ed8d..c4ba2168 100644 --- a/academy_core/admin_audit/impl/src/tests/record.rs +++ b/academy_core/admin_audit/impl/src/tests/record.rs @@ -1,198 +1,154 @@ +use super::{Sut, make_entry, make_request, now}; +use crate::AdminAuditFeatureServiceImpl; use academy_auth_contracts::MockAuthService; -use academy_core_admin_audit_contracts::{AdminAuditFeatureService, AdminAuditRequest}; +use academy_core_admin_audit_contracts::{ + AdminAuditActor, AdminAuditCapture, AdminAuditCredential, AdminAuditFeatureService, + AdminAuditRequest, +}; use academy_demo::{ - UUID1, session::{ADMIN_1, FOO_1}, user::{ADMIN, FOO}, }; -use academy_models::{ - admin_audit::AdminAuditLogEntry, - session::{Session, SessionOrigin}, -}; +use academy_models::session::{Session, SessionOrigin}; use academy_persistence_contracts::{ MockDatabase, admin_audit::MockAdminAuditRepository, session::MockSessionRepository, }; use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; -use super::{Sut, make_entry, make_request, now}; -use crate::AdminAuditFeatureServiceImpl; - -fn sut(expected: AdminAuditLogEntry) -> Sut { - AdminAuditFeatureServiceImpl { - db: MockDatabase::build(true), - auth: MockAuthService::new().with_authenticate(Some((ADMIN.user.clone(), ADMIN_1.clone()))), - id: MockIdService::new().with_generate(expected.id), - time: MockTimeService::new().with_now(now()), - admin_audit_repo: MockAdminAuditRepository::new().with_create(expected), - session_repo: MockSessionRepository::new().with_get(ADMIN_1.id, Some(ADMIN_1.clone())), - } -} - -/// A session the administrator opened in FOO's account. -fn impersonation(admin: Option) -> Session { - Session { - id: UUID1.into(), - device_name: None, - mfa_verified: false, - origin: SessionOrigin::Impersonation { admin }, - ..FOO_1.clone() - } -} - -fn publication_request() -> AdminAuditRequest { - AdminAuditRequest { - method: "PUT".try_into().unwrap(), - path: "/auth/users/me/publication".try_into().unwrap(), - route: Some("/auth/users/me/publication".try_into().unwrap()), - status: 403, - ..make_request() - } -} - -#[tokio::test] -async fn admin() { - // Arrange - let sut = sut(make_entry()); - - // Act - let result = sut.record(make_request()).await; - - // Assert - assert!(result.unwrap()); -} - -/// A request that was rejected is recorded with the status code it was -/// answered with. -#[tokio::test] -async fn admin_rejected_request() { - // Arrange - let sut = sut(AdminAuditLogEntry { - status: 403, - ..make_entry() - }); - - // Act - let result = sut - .record(AdminAuditRequest { - status: 403, - ..make_request() - }) - .await; - - // Assert - assert!(result.unwrap()); -} - -/// Without a matched route there is no path parameter to read the affected -/// user from. -#[tokio::test] -async fn admin_without_route() { - // Arrange - let sut = sut(AdminAuditLogEntry { - target_user_id: None, - ..make_entry() - }); - - // Act - let result = sut - .record(AdminAuditRequest { - route: None, - ..make_request() - }) - .await; - - // Assert - assert!(result.unwrap()); -} - -/// Requests in a session an administrator opened in someone else's account -/// carry that account's token. They are recorded for the administrator and act -/// on that account, including a refused attempt to share its profile. #[tokio::test] -async fn impersonation_session_recorded_for_admin() { - // Arrange - let session = impersonation(Some(ADMIN.user.id)); - let request = publication_request(); - let expected = AdminAuditLogEntry { - id: UUID1.into(), - at: now(), - admin_user_id: ADMIN.user.id, - method: request.method.clone(), - path: request.path.clone(), - target_user_id: Some(FOO.user.id), - status: 403, - request_id: request.request_id.clone(), - }; - - let sut = AdminAuditFeatureServiceImpl { - db: MockDatabase::build(true), - auth: MockAuthService::new().with_authenticate(Some((FOO.user.clone(), session.clone()))), - id: MockIdService::new().with_generate(expected.id), - time: MockTimeService::new().with_now(now()), - admin_audit_repo: MockAdminAuditRepository::new().with_create(expected), - session_repo: MockSessionRepository::new().with_get(session.id, Some(session)), - }; - - // Act - let result = sut.record(request).await; - - // Assert - assert!(result.unwrap()); -} - -/// The CLI signs in without an administrator account, so there is nobody to -/// attribute its requests to. -#[tokio::test] -async fn cli_impersonation_session() { - // Arrange - let session = impersonation(None); - - let sut = AdminAuditFeatureServiceImpl { - db: MockDatabase::build(false), - auth: MockAuthService::new().with_authenticate(Some((FOO.user.clone(), session.clone()))), - session_repo: MockSessionRepository::new().with_get(session.id, Some(session)), - ..Sut::default() - }; - - // Act - let result = sut.record(publication_request()).await; - - // Assert - assert!(!result.unwrap()); +async fn captures_durable_actor_before_the_handler() { + for (user, session, expected) in [ + ( + ADMIN.user.clone(), + ADMIN_1.clone(), + AdminAuditCapture::Recorded(AdminAuditActor { + user_id: ADMIN.user.id, + admin_user_id: Some(ADMIN.user.id), + impersonated: false, + }), + ), + ( + FOO.user.clone(), + Session { + origin: SessionOrigin::Impersonation { + admin: Some(ADMIN.user.id), + }, + ..FOO_1.clone() + }, + AdminAuditCapture::Recorded(AdminAuditActor { + user_id: FOO.user.id, + admin_user_id: Some(ADMIN.user.id), + impersonated: true, + }), + ), + ( + FOO.user.clone(), + Session { + origin: SessionOrigin::Impersonation { admin: None }, + ..FOO_1.clone() + }, + AdminAuditCapture::Recorded(AdminAuditActor { + user_id: FOO.user.id, + admin_user_id: None, + impersonated: true, + }), + ), + ( + FOO.user.clone(), + FOO_1.clone(), + AdminAuditCapture::Unrecorded, + ), + ] { + let mut auth = + MockAuthService::new().with_authenticate(Some((user.clone(), session.clone()))); + let mut session_repo = + MockSessionRepository::new().with_get(session.id, Some(session.clone())); + if matches!(expected, AdminAuditCapture::Recorded(_)) { + auth = auth.with_authenticate_in_transaction(Some((user, session.clone()))); + session_repo = session_repo.with_get(session.id, Some(session)); + } + let sut = AdminAuditFeatureServiceImpl { + db: MockDatabase::build_expect_rollback(), + auth, + session_repo, + ..Sut::default() + }; + assert_eq!( + sut.capture(AdminAuditCredential::Access(&"token".into())) + .await + .unwrap(), + expected + ); + } } #[tokio::test] -async fn no_admin() { - // Arrange - let auth = MockAuthService::new().with_authenticate(Some((FOO.user.clone(), FOO_1.clone()))); - +async fn invalid_credential_has_no_actor() { let sut = AdminAuditFeatureServiceImpl { - auth, - db: MockDatabase::build(false), - session_repo: MockSessionRepository::new().with_get(FOO_1.id, Some(FOO_1.clone())), + db: MockDatabase::new(), + auth: MockAuthService::new().with_authenticate(None), ..Sut::default() }; - - // Act - let result = sut.record(make_request()).await; - - // Assert - assert!(!result.unwrap()); + assert_eq!( + sut.capture(AdminAuditCredential::Access(&"token".into())) + .await + .unwrap(), + AdminAuditCapture::InvalidCredential + ); } +// Recording uses only captured facts, with no second authentication or session +// lookup that could lose an actor through rotation, revocation or account deletion. #[tokio::test] -async fn unauthenticated() { - // Arrange - let auth = MockAuthService::new().with_authenticate(None); - - let sut = AdminAuditFeatureServiceImpl { - auth, - ..Sut::default() - }; - - // Act - let result = sut.record(make_request()).await; - - // Assert - assert!(!result.unwrap()); +async fn records_captured_actor_and_final_status_without_live_credentials() { + for (actor, route, status, target) in [ + ( + make_request().actor, + make_request().route, + 200, + Some(ADMIN.user.id), + ), + (make_request().actor, None, 403, None), + ( + AdminAuditActor { + user_id: FOO.user.id, + admin_user_id: Some(ADMIN.user.id), + impersonated: true, + }, + None, + 401, + Some(FOO.user.id), + ), + ( + AdminAuditActor { + user_id: FOO.user.id, + admin_user_id: None, + impersonated: true, + }, + None, + 200, + Some(FOO.user.id), + ), + ] { + let request = AdminAuditRequest { + actor, + route, + status, + ..make_request() + }; + let expected = academy_models::admin_audit::AdminAuditLogEntry { + admin_user_id: actor.admin_user_id, + target_user_id: target, + status, + ..make_entry() + }; + let sut = AdminAuditFeatureServiceImpl { + db: MockDatabase::build(true), + id: MockIdService::new().with_generate(expected.id), + time: MockTimeService::new().with_now(now()), + admin_audit_repo: MockAdminAuditRepository::new().with_create(expected), + ..Sut::default() + }; + assert!(sut.record(request).await.unwrap()); + } } diff --git a/academy_core/mfa/impl/src/lib.rs b/academy_core/mfa/impl/src/lib.rs index 84bf6416..b268ef98 100644 --- a/academy_core/mfa/impl/src/lib.rs +++ b/academy_core/mfa/impl/src/lib.rs @@ -12,7 +12,10 @@ use academy_models::{ user::UserIdOrSelf, }; use academy_persistence_contracts::{ - Database, Transaction, mfa::MfaRepository, user::UserRepository, + Database, Transaction, + mfa::MfaRepository, + session::{SessionRepository, ensure_owner_sign_in}, + user::UserRepository, }; use academy_utils::trace_instrument; use anyhow::Context; @@ -35,6 +38,7 @@ pub struct MfaFeatureServiceImpl< MfaRecovery, MfaDisable, MfaTotpDevice, + SessionRepo, > { db: Db, auth: Auth, @@ -43,10 +47,21 @@ pub struct MfaFeatureServiceImpl< mfa_recovery: MfaRecovery, mfa_disable: MfaDisable, mfa_totp_device: MfaTotpDevice, + session_repo: SessionRepo, } -impl MfaFeatureService - for MfaFeatureServiceImpl +impl + MfaFeatureService + for MfaFeatureServiceImpl< + Db, + Auth, + UserRepo, + MfaRepo, + MfaRecovery, + MfaDisable, + MfaTotpDevice, + SessionRepo, + > where Db: Database, Auth: AuthService, @@ -55,6 +70,7 @@ where MfaRecovery: MfaRecoveryService, MfaDisable: MfaDisableService, MfaTotpDevice: MfaTotpDeviceService, + SessionRepo: SessionRepository, { #[tracing::instrument(skip(self, token))] async fn initialize( @@ -86,6 +102,7 @@ where .await .map_auth_err()?; auth.ensure_self_or_admin(user_id).map_auth_err()?; + ensure_owner_sign_in(&self.session_repo, &mut txn, auth.session_id).await?; trace!("check user existence"); if !self @@ -161,6 +178,7 @@ where .await .map_auth_err()?; auth.ensure_self_or_admin(user_id).map_auth_err()?; + ensure_owner_sign_in(&self.session_repo, &mut txn, auth.session_id).await?; trace!("check user existence"); if !self @@ -243,6 +261,7 @@ where .await .map_auth_err()?; auth.ensure_self_or_admin(user_id).map_auth_err()?; + ensure_owner_sign_in(&self.session_repo, &mut txn, auth.session_id).await?; trace!("check user existence"); if !self diff --git a/academy_core/mfa/impl/src/tests/disable.rs b/academy_core/mfa/impl/src/tests/disable.rs index ff616e95..82972bac 100644 --- a/academy_core/mfa/impl/src/tests/disable.rs +++ b/academy_core/mfa/impl/src/tests/disable.rs @@ -11,6 +11,7 @@ use academy_models::{ auth::{AuthError, AuthenticateError, AuthorizeError}, user::UserIdOrSelf, }; +use academy_persistence_contracts::session::MockSessionRepository; use academy_persistence_contracts::{ MockDatabase, mfa::MockMfaRepository, user::MockUserRepository, }; @@ -37,6 +38,13 @@ async fn ok() { let mfa_disable = MockMfaDisableService::new().with_disable(FOO.user.id); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, @@ -111,6 +119,13 @@ async fn user_not_found() { .with_exists(FOO.user.id, false); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, auth, db, user_repo, @@ -138,6 +153,13 @@ async fn not_initialized() { let mfa_repo = MockMfaRepository::new().with_list_totp_devices_by_user(FOO.user.id, vec![]); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, @@ -167,6 +189,13 @@ async fn not_enabled() { .with_list_totp_devices_by_user(FOO.user.id, vec![FOO_TOTP_1.clone()]); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, diff --git a/academy_core/mfa/impl/src/tests/enable.rs b/academy_core/mfa/impl/src/tests/enable.rs index 2ecfdff9..70327ce2 100644 --- a/academy_core/mfa/impl/src/tests/enable.rs +++ b/academy_core/mfa/impl/src/tests/enable.rs @@ -14,6 +14,7 @@ use academy_models::{ mfa::{MfaRecoveryCode, TotpCode}, user::UserIdOrSelf, }; +use academy_persistence_contracts::session::MockSessionRepository; use academy_persistence_contracts::{ MockDatabase, mfa::MockMfaRepository, user::MockUserRepository, }; @@ -47,6 +48,13 @@ async fn ok() { let mfa_recovery = MockMfaRecoveryService::new().with_setup(FOO.user.id, expected.clone()); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, @@ -128,6 +136,13 @@ async fn user_not_found() { .with_exists(FOO.user.id, false); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, auth, db, user_repo, @@ -160,6 +175,13 @@ async fn already_enabled() { ); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, @@ -190,6 +212,13 @@ async fn not_initialized() { let mfa_repo = MockMfaRepository::new().with_list_totp_devices_by_user(FOO.user.id, vec![]); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, @@ -227,6 +256,13 @@ async fn invalid_code() { ); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, diff --git a/academy_core/mfa/impl/src/tests/initialize.rs b/academy_core/mfa/impl/src/tests/initialize.rs index 06e646e7..704292d7 100644 --- a/academy_core/mfa/impl/src/tests/initialize.rs +++ b/academy_core/mfa/impl/src/tests/initialize.rs @@ -12,6 +12,7 @@ use academy_models::{ mfa::TotpSetup, user::UserIdOrSelf, }; +use academy_persistence_contracts::session::MockSessionRepository; use academy_persistence_contracts::{ MockDatabase, mfa::MockMfaRepository, user::MockUserRepository, }; @@ -40,6 +41,13 @@ async fn new() { MockMfaTotpDeviceService::new().with_create(FOO.user.id, expected.clone()); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, @@ -77,6 +85,13 @@ async fn reset_disabled() { MockMfaTotpDeviceService::new().with_reset(FOO_TOTP_1.id, expected.clone()); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, @@ -151,6 +166,13 @@ async fn user_not_found() { .with_exists(FOO.user.id, false); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, auth, db, user_repo, @@ -181,6 +203,13 @@ async fn already_enabled() { ); let sut = MfaFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_repo, diff --git a/academy_core/mfa/impl/src/tests/mod.rs b/academy_core/mfa/impl/src/tests/mod.rs index e67d0b71..0d5a49e0 100644 --- a/academy_core/mfa/impl/src/tests/mod.rs +++ b/academy_core/mfa/impl/src/tests/mod.rs @@ -4,7 +4,8 @@ use academy_core_mfa_contracts::{ totp_device::MockMfaTotpDeviceService, }; use academy_persistence_contracts::{ - MockDatabase, MockTransaction, mfa::MockMfaRepository, user::MockUserRepository, + MockDatabase, MockTransaction, mfa::MockMfaRepository, session::MockSessionRepository, + user::MockUserRepository, }; use crate::MfaFeatureServiceImpl; @@ -21,4 +22,5 @@ type Sut = MfaFeatureServiceImpl< MockMfaRecoveryService, MockMfaDisableService, MockMfaTotpDeviceService, + MockSessionRepository, >; diff --git a/academy_core/oauth2/impl/src/lib.rs b/academy_core/oauth2/impl/src/lib.rs index 80953d76..7494c75b 100644 --- a/academy_core/oauth2/impl/src/lib.rs +++ b/academy_core/oauth2/impl/src/lib.rs @@ -23,7 +23,10 @@ use academy_models::{ user::{UserId, UserIdOrSelf}, }; use academy_persistence_contracts::{ - Database, Transaction, oauth2::OAuth2Repository, user::UserRepository, + Database, Transaction, + oauth2::OAuth2Repository, + session::{SessionRepository, ensure_owner_sign_in}, + user::UserRepository, }; use academy_utils::trace_instrument; use anyhow::Context; @@ -48,6 +51,7 @@ pub struct OAuth2FeatureServiceImpl< OAuth2Login, OAuth2Registration, Session, + SessionRepo, > { db: Db, auth: Auth, @@ -58,6 +62,7 @@ pub struct OAuth2FeatureServiceImpl< oauth2_login: OAuth2Login, oauth2_registration: OAuth2Registration, session: Session, + session_repo: SessionRepo, config: OAuth2FeatureConfig, } @@ -83,6 +88,7 @@ impl< OAuth2LoginS, OAuth2RegistrationS, Session, + SessionRepo, > OAuth2FeatureService for OAuth2FeatureServiceImpl< Db, @@ -94,6 +100,7 @@ impl< OAuth2LoginS, OAuth2RegistrationS, Session, + SessionRepo, > where Db: Database, @@ -105,6 +112,7 @@ where OAuth2LoginS: OAuth2LoginService, OAuth2RegistrationS: OAuth2RegistrationService, Session: SessionService, + SessionRepo: SessionRepository, { async fn begin_recipient( &self, @@ -257,6 +265,7 @@ where .await .map_auth_err()?; auth.ensure_self_or_admin(user_id).map_auth_err()?; + ensure_owner_sign_in(&self.session_repo, &mut txn, auth.session_id).await?; if !self .user_repo @@ -333,6 +342,7 @@ where .await .map_auth_err()?; auth.ensure_self_or_admin(user_id).map_auth_err()?; + ensure_owner_sign_in(&self.session_repo, &mut txn, auth.session_id).await?; let link = self .oauth2_repo diff --git a/academy_core/oauth2/impl/src/tests/create_link.rs b/academy_core/oauth2/impl/src/tests/create_link.rs index b92a22d7..19226958 100644 --- a/academy_core/oauth2/impl/src/tests/create_link.rs +++ b/academy_core/oauth2/impl/src/tests/create_link.rs @@ -15,6 +15,7 @@ use academy_models::{ oauth2::OAuth2PendingAuthorization, user::UserIdOrSelf, }; +use academy_persistence_contracts::session::MockSessionRepository; use academy_persistence_contracts::{MockDatabase, user::MockUserRepository}; use academy_utils::assert_matches; @@ -50,6 +51,13 @@ async fn ok() { ); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, user_repo, @@ -131,6 +139,13 @@ async fn not_found() { .with_exists(FOO.user.id, false); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, db, auth, user_repo, @@ -163,6 +178,13 @@ async fn invalid_state() { MockOAuth2AuthorizationService::new().with_consume(STATE.try_into().unwrap(), None); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, user_repo, @@ -206,6 +228,13 @@ async fn invalid_provider() { .with_login(login, Err(OAuth2LoginServiceError::InvalidProvider)); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, user_repo, @@ -243,6 +272,13 @@ async fn invalid_code() { .with_login(login(), Err(OAuth2LoginServiceError::InvalidCode)); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, user_repo, @@ -287,6 +323,13 @@ async fn remote_already_linked() { ); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, user_repo, @@ -325,6 +368,13 @@ async fn anonymous_authorization() { .with_consume(STATE.try_into().unwrap(), Some(pending_authorization())); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, user_repo, @@ -359,6 +409,13 @@ async fn authorization_of_another_account() { ); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, user_repo, diff --git a/academy_core/oauth2/impl/src/tests/delete_link.rs b/academy_core/oauth2/impl/src/tests/delete_link.rs index b94b4c15..c3d7984c 100644 --- a/academy_core/oauth2/impl/src/tests/delete_link.rs +++ b/academy_core/oauth2/impl/src/tests/delete_link.rs @@ -6,6 +6,7 @@ use academy_demo::{ user::{ADMIN, BAR, FOO}, }; use academy_models::auth::{AuthError, AuthenticateError, AuthorizeError}; +use academy_persistence_contracts::session::MockSessionRepository; use academy_persistence_contracts::{ MockDatabase, oauth2::MockOAuth2Repository, user::MockUserRepository, }; @@ -32,6 +33,13 @@ async fn ok() { ); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, oauth2_repo, @@ -111,6 +119,13 @@ async fn not_found() { .with_lock_account(ADMIN.user.id, true); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, db, auth, oauth2_repo, @@ -144,6 +159,13 @@ async fn user_id_mismatch() { .with_lock_account(ADMIN.user.id, true); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, db, auth, oauth2_repo, @@ -182,6 +204,13 @@ async fn last_login_method() { ); let sut = OAuth2FeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, db, auth, oauth2_repo, diff --git a/academy_core/oauth2/impl/src/tests/mod.rs b/academy_core/oauth2/impl/src/tests/mod.rs index 2d58276f..901132fb 100644 --- a/academy_core/oauth2/impl/src/tests/mod.rs +++ b/academy_core/oauth2/impl/src/tests/mod.rs @@ -9,7 +9,8 @@ use academy_core_session_contracts::session::MockSessionService; use academy_demo::oauth2::{TEST_OAUTH2_PROVIDER, TEST_OAUTH2_PROVIDER_ID}; use academy_models::oauth2::{OAuth2Callback, OAuth2Login, OAuth2PendingAuthorization}; use academy_persistence_contracts::{ - MockDatabase, MockTransaction, oauth2::MockOAuth2Repository, user::MockUserRepository, + MockDatabase, MockTransaction, oauth2::MockOAuth2Repository, session::MockSessionRepository, + user::MockUserRepository, }; use crate::{OAuth2FeatureConfig, OAuth2FeatureServiceImpl}; @@ -31,6 +32,7 @@ type Sut = OAuth2FeatureServiceImpl< MockOAuth2LoginService, MockOAuth2RegistrationService, MockSessionService, + MockSessionRepository, >; const STATE: &str = "vJyLhIytPtnPKfJVpTPdRVQdEuYSbcCwOTrnegrmwtIkVfSHiWMSuxaMYrgvhbTx"; diff --git a/academy_core/session/impl/src/lib.rs b/academy_core/session/impl/src/lib.rs index 3170cad1..8a268a18 100644 --- a/academy_core/session/impl/src/lib.rs +++ b/academy_core/session/impl/src/lib.rs @@ -221,22 +221,27 @@ where return Err(SessionImpersonateError::NotFound); } + let origin = self + .session_repo + .get(&mut txn, auth.session_id) + .await? + .map(|session| match session.origin { + origin @ SessionOrigin::Impersonation { .. } => origin, + _ if !session.is_owner_sign_in() => SessionOrigin::Impersonation { admin: None }, + _ => SessionOrigin::Impersonation { + admin: Some(auth.user_id), + }, + }) + .ok_or(academy_models::auth::AuthenticateError::InvalidToken) + .map_auth_err()?; + // Impersonation never involves the second factor of the impersonated // user, so the new session does not grant administrative privileges. // The session records the administrator, which keeps owner-only // decisions out of its reach for its whole lifetime. let login = self .session - .create( - &mut txn, - user_composite, - None, - false, - false, - SessionOrigin::Impersonation { - admin: Some(auth.user_id), - }, - ) + .create(&mut txn, user_composite, None, false, false, origin) .await .context("Failed to create session")?; diff --git a/academy_core/session/impl/src/tests/impersonate.rs b/academy_core/session/impl/src/tests/impersonate.rs index 1a5d8d6e..3eccdc86 100644 --- a/academy_core/session/impl/src/tests/impersonate.rs +++ b/academy_core/session/impl/src/tests/impersonate.rs @@ -52,6 +52,8 @@ async fn ok() { db, user_repo, session, + session_repo: academy_persistence_contracts::session::MockSessionRepository::new() + .with_get(ADMIN_1.id, Some(ADMIN_1.clone())), ..Sut::default() }; diff --git a/academy_core/user/impl/src/lib.rs b/academy_core/user/impl/src/lib.rs index d091f954..e12c8914 100644 --- a/academy_core/user/impl/src/lib.rs +++ b/academy_core/user/impl/src/lib.rs @@ -416,16 +416,16 @@ where return Err(UserUpdateError::CannotDemoteSelf); } - // Changing the old opt-out flag shares or withdraws the profile, which - // only the owner's own sign-in decides (see the publication route). - // Support changes another account with its own token instead. - if profile_update.leaderboard_opt_out.is_update() - && user_id == auth.user_id + // Credential changes would let the operator sign in as the owner later. + // Check every target, including the CLI's impersonated admin account. + if (password_changed + || email.is_update() + || email_verified.is_update() + || profile_update.leaderboard_opt_out.is_update()) && !self .session_repo .get(&mut txn, auth.session_id) - .await - .context("Failed to get session from database")? + .await? .is_some_and(|session| session.is_owner_sign_in()) { return Err(UserUpdateError::NotOwnerSignIn); diff --git a/academy_core/user/impl/src/publication.rs b/academy_core/user/impl/src/publication.rs index e63f1ebc..5e93e559 100644 --- a/academy_core/user/impl/src/publication.rs +++ b/academy_core/user/impl/src/publication.rs @@ -113,6 +113,12 @@ where .await .map_auth_err()?; current_auth.ensure_admin().map_auth_err()?; + academy_persistence_contracts::session::ensure_owner_sign_in( + &self.session_repo, + &mut txn, + current_auth.session_id, + ) + .await?; self.user_repo .get_composite(&mut txn, user_id) .await? diff --git a/academy_core/user/impl/src/tests/update_user/email.rs b/academy_core/user/impl/src/tests/update_user/email.rs index 019b7515..073aed69 100644 --- a/academy_core/user/impl/src/tests/update_user/email.rs +++ b/academy_core/user/impl/src/tests/update_user/email.rs @@ -8,6 +8,7 @@ use academy_demo::{ user::{ADMIN, FOO}, }; use academy_models::user::{User, UserComposite, UserIdOrSelf}; +use academy_persistence_contracts::session::MockSessionRepository; use academy_persistence_contracts::{MockDatabase, user::MockUserRepository}; use academy_utils::assert_matches; @@ -43,6 +44,13 @@ async fn update_email_self() { ); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_update, @@ -103,6 +111,13 @@ async fn update_email_admin() { ); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, auth, db, user_update, @@ -163,6 +178,13 @@ async fn update_email_admin_verified() { ); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, auth, db, user_update, @@ -224,6 +246,13 @@ async fn update_email_admin_unverified() { ); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, auth, db, user_update, @@ -287,6 +316,13 @@ async fn update_set_email_verified() { ); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, auth, db, user_update, @@ -343,6 +379,13 @@ async fn update_set_email_unverified() { ); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(ADMIN_1.clone()))))); + repo + }, auth, db, user_update, @@ -390,6 +433,13 @@ async fn update_email_conflict() { ); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, db, user_update, diff --git a/academy_core/user/impl/src/tests/update_user/password.rs b/academy_core/user/impl/src/tests/update_user/password.rs index cc8e2ede..7d36ac4c 100644 --- a/academy_core/user/impl/src/tests/update_user/password.rs +++ b/academy_core/user/impl/src/tests/update_user/password.rs @@ -5,6 +5,7 @@ use academy_core_user_contracts::{ }; use academy_demo::{session::FOO_1, user::FOO}; use academy_models::user::{UserIdOrSelf, UserPassword}; +use academy_persistence_contracts::session::MockSessionRepository; use academy_persistence_contracts::{MockDatabase, user::MockUserRepository}; use academy_utils::{Apply, assert_matches, patch::PatchValue}; @@ -30,6 +31,13 @@ async fn update_password() { MockUserUpdateService::new().with_update_password(FOO.user.id, new_password.clone()); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, session: academy_core_session_contracts::session::MockSessionService::new() .with_revoke_by_user(FOO.user.id, Vec::new()), @@ -77,6 +85,13 @@ async fn remove_password_oauth() { .with_remove_password_hash(FOO.user.id, true); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, session: academy_core_session_contracts::session::MockSessionService::new() .with_revoke_by_user(FOO.user.id, Vec::new()), @@ -127,6 +142,13 @@ async fn remove_password_no_oauth() { ); let sut = UserFeatureServiceImpl { + session_repo: { + let mut repo = MockSessionRepository::new(); + repo.expect_get() + .times(0..=1) + .return_once(|_, _| Box::pin(std::future::ready(Ok(Some(FOO_1.clone()))))); + repo + }, auth, cache: academy_cache_contracts::MockCacheService::new().with_remove( crate::email_confirmation::reset_password_cache_key(FOO.user.id), diff --git a/academy_models/src/admin_audit.rs b/academy_models/src/admin_audit.rs index 505e75e0..f8ce87d0 100644 --- a/academy_models/src/admin_audit.rs +++ b/academy_models/src/admin_audit.rs @@ -23,7 +23,8 @@ pub struct AdminAuditLogEntry { pub at: DateTime, /// The administrator whose access token authenticated the request, or who /// opened the session in someone else's account it was made in - pub admin_user_id: UserId, + /// None identifies a server CLI operator with no known admin account. + pub admin_user_id: Option, /// HTTP method of the request pub method: RequestMethod, /// Path of the request, without the query string diff --git a/academy_models/src/auth.rs b/academy_models/src/auth.rs index 7d4e5985..28b14a22 100644 --- a/academy_models/src/auth.rs +++ b/academy_models/src/auth.rs @@ -32,6 +32,8 @@ pub enum AuthorizeError { Admin, #[error("The session of the administrator was not authenticated with a second factor.")] AdminMfa, + #[error("Only the owner's own sign-in can change credentials or profile visibility.")] + OwnerSignIn, #[error("The user's email address is not verified.")] EmailVerified, } diff --git a/academy_persistence/contracts/src/session.rs b/academy_persistence/contracts/src/session.rs index 7cb22105..fd5f0ad0 100644 --- a/academy_persistence/contracts/src/session.rs +++ b/academy_persistence/contracts/src/session.rs @@ -271,3 +271,23 @@ impl MockSessionRepository { self } } + +/// Credentials and publication choices cannot be changed through an +/// impersonation, including a CLI session and a legacy session of unknown origin. +/// Call after authenticating in the same write transaction. +pub async fn ensure_owner_sign_in( + repo: &impl SessionRepository, + txn: &mut Txn, + session_id: SessionId, +) -> Result<(), academy_models::auth::AuthError> { + if repo + .get(txn, session_id) + .await + .map_err(academy_models::auth::AuthenticateError::Other)? + .is_some_and(|session| session.is_owner_sign_in()) + { + Ok(()) + } else { + Err(academy_models::auth::AuthorizeError::OwnerSignIn.into()) + } +} diff --git a/academy_persistence/postgres/clorinde/src/queries/admin_audit.rs b/academy_persistence/postgres/clorinde/src/queries/admin_audit.rs index eb5e6104..f65d4e37 100644 --- a/academy_persistence/postgres/clorinde/src/queries/admin_audit.rs +++ b/academy_persistence/postgres/clorinde/src/queries/admin_audit.rs @@ -4,7 +4,7 @@ pub struct CreateParams { pub id: uuid::Uuid, pub at: chrono::DateTime, - pub admin_user_id: uuid::Uuid, + pub admin_user_id: Option, pub method: T1, pub path: T2, pub target_user_id: Option, @@ -27,7 +27,7 @@ pub struct CountParams { pub struct AdminAuditLogEntry { pub id: uuid::Uuid, pub at: chrono::DateTime, - pub admin_user_id: uuid::Uuid, + pub admin_user_id: Option, pub method: String, pub path: String, pub target_user_id: Option, @@ -37,7 +37,7 @@ pub struct AdminAuditLogEntry { pub struct AdminAuditLogEntryBorrowed<'a> { pub id: uuid::Uuid, pub at: chrono::DateTime, - pub admin_user_id: uuid::Uuid, + pub admin_user_id: Option, pub method: &'a str, pub path: &'a str, pub target_user_id: Option, @@ -231,7 +231,7 @@ impl CreateStmt { client: &'c C, id: &'a uuid::Uuid, at: &'a chrono::DateTime, - admin_user_id: &'a uuid::Uuid, + admin_user_id: &'a Option, method: &'a T1, path: &'a T2, target_user_id: &'a Option, diff --git a/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql b/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql index 98095d38..57d3ba09 100644 --- a/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql +++ b/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql @@ -1,3 +1,12 @@ +-- Dropping origin columns while a delegated session exists would erase its +-- provenance. The migration CLI must refuse that rollback, preserving sessions. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM sessions WHERE origin='impersonation') THEN + RAISE EXCEPTION 'Cannot remove provenance while impersonation sessions exist'; + END IF; +END $$; + -- Prefer rolling back the application while keeping these columns: an older -- backend leaves new sessions at 'legacy'. Removing them forgets which open -- sessions an administrator started, and those count as the owner's again. diff --git a/academy_persistence/postgres/migrations/2026-10-05-180000_operator_audit/down.sql b/academy_persistence/postgres/migrations/2026-10-05-180000_operator_audit/down.sql new file mode 100644 index 00000000..26972635 --- /dev/null +++ b/academy_persistence/postgres/migrations/2026-10-05-180000_operator_audit/down.sql @@ -0,0 +1,3 @@ +-- Refuse schema rollback while operator entries exist. Application rollback +-- must retain this column definition; deleting audit evidence is never safe. +ALTER TABLE admin_audit_log ALTER COLUMN admin_user_id SET NOT NULL; diff --git a/academy_persistence/postgres/migrations/2026-10-05-180000_operator_audit/up.sql b/academy_persistence/postgres/migrations/2026-10-05-180000_operator_audit/up.sql new file mode 100644 index 00000000..c2c427d6 --- /dev/null +++ b/academy_persistence/postgres/migrations/2026-10-05-180000_operator_audit/up.sql @@ -0,0 +1,3 @@ +-- CLI impersonation has no known administrator account. Preserve that fact +-- instead of dropping its requests or attributing them to the account owner. +ALTER TABLE admin_audit_log ALTER COLUMN admin_user_id DROP NOT NULL; diff --git a/academy_persistence/postgres/queries/admin_audit.sql b/academy_persistence/postgres/queries/admin_audit.sql index e5033673..91e57226 100644 --- a/academy_persistence/postgres/queries/admin_audit.sql +++ b/academy_persistence/postgres/queries/admin_audit.sql @@ -1,6 +1,6 @@ ---: AdminAuditLogEntry (target_user_id?) +--: AdminAuditLogEntry (admin_user_id?, target_user_id?) ---! create (target_user_id?) +--! create (admin_user_id?, target_user_id?) insert into admin_audit_log (id, at, admin_user_id, method, path, target_user_id, status, request_id) values (:id, :at, :admin_user_id, :method, :path, :target_user_id, :status, :request_id); diff --git a/academy_persistence/postgres/src/admin_audit.rs b/academy_persistence/postgres/src/admin_audit.rs index 433ae72e..756c1a5f 100644 --- a/academy_persistence/postgres/src/admin_audit.rs +++ b/academy_persistence/postgres/src/admin_audit.rs @@ -30,7 +30,7 @@ impl AdminAuditRepository for PostgresAdminAuditRepository let params = CreateParams { id: *entry.id, at: entry.at.into(), - admin_user_id: *entry.admin_user_id, + admin_user_id: entry.admin_user_id.map(|id| *id), method: &*entry.method, path: &*entry.path, target_user_id: entry.target_user_id.map(|user_id| *user_id), @@ -106,7 +106,7 @@ fn decode_entry( Ok(AdminAuditLogEntry { id: value.id.into(), at: value.at.into(), - admin_user_id: value.admin_user_id.into(), + admin_user_id: value.admin_user_id.map(Into::into), method: value.method.try_into()?, path: value.path.try_into()?, target_user_id: value.target_user_id.map(Into::into), diff --git a/academy_persistence/postgres/tests/invoice_identity.rs b/academy_persistence/postgres/tests/invoice_identity.rs index 429a04b3..334aaa25 100644 --- a/academy_persistence/postgres/tests/invoice_identity.rs +++ b/academy_persistence/postgres/tests/invoice_identity.rs @@ -143,7 +143,8 @@ async fn invoice_identity_forward_controls() { "2026-09-26-120000_daily_learning_heart_receipts", "2026-10-03-140000_profile_publications", "2026-10-04-070000_durable_totp_steps", - "2026-10-04-175659_session_origin" + "2026-10-04-175659_session_origin", + "2026-10-05-180000_operator_audit" ] ); } @@ -554,7 +555,8 @@ async fn pending_invoice_rich_export_controls() { "2026-09-26-120000_daily_learning_heart_receipts", "2026-10-03-140000_profile_publications", "2026-10-04-070000_durable_totp_steps", - "2026-10-04-175659_session_origin" + "2026-10-04-175659_session_origin", + "2026-10-05-180000_operator_audit" ] ); } @@ -708,7 +710,8 @@ async fn erased_owner_identity_conflict_controls() { "2026-09-26-120000_daily_learning_heart_receipts", "2026-10-03-140000_profile_publications", "2026-10-04-070000_durable_totp_steps", - "2026-10-04-175659_session_origin" + "2026-10-04-175659_session_origin", + "2026-10-05-180000_operator_audit" ] ); } diff --git a/academy_persistence/postgres/tests/migrations.rs b/academy_persistence/postgres/tests/migrations.rs index 8051b0c9..e07c42ed 100644 --- a/academy_persistence/postgres/tests/migrations.rs +++ b/academy_persistence/postgres/tests/migrations.rs @@ -217,3 +217,83 @@ async fn migrations_with_data() { current_refusal(&db).await; historical_matrix(true).await; } + +/// Making the actor nullable preserves history; reversal never deletes an +/// unknown operator's evidence to satisfy the old schema. +#[tokio::test] +async fn operator_audit_migration_preserves_entries_and_refuses_lossy_rollback() { + const NAME: &str = "2026-10-05-180000_operator_audit"; + let db = common::setup_before(NAME, true).await; + let txn = db.begin_transaction().await.unwrap(); + txn.txn().execute("INSERT INTO admin_audit_log(id,at,admin_user_id,method,path,status,request_id) VALUES(gen_random_uuid(),clock_timestamp(),$1,'PATCH','/auth/users/me',200,'known-actor')", &[&*academy_demo::user::ADMIN.user.id]).await.unwrap(); + let before: String = txn + .txn() + .query_one( + "SELECT jsonb_agg(to_jsonb(a))::text FROM admin_audit_log a", + &[], + ) + .await + .unwrap() + .get(0); + txn.commit().await.unwrap(); + assert_eq!(db.run_migrations(Some(1)).await.unwrap(), [NAME]); + let txn = db.begin_transaction().await.unwrap(); + let after: String = txn + .txn() + .query_one( + "SELECT jsonb_agg(to_jsonb(a))::text FROM admin_audit_log a", + &[], + ) + .await + .unwrap() + .get(0); + assert_eq!(after, before); + txn.txn().execute("INSERT INTO admin_audit_log(id,at,admin_user_id,method,path,status,request_id) VALUES(gen_random_uuid(),clock_timestamp(),NULL,'PUT','/auth/session',200,'unknown-operator')", &[]).await.unwrap(); + txn.commit().await.unwrap(); + assert!(db.revert_migrations(Some(1)).await.is_err()); + let txn = db.begin_transaction().await.unwrap(); + let count: i64 = txn + .txn() + .query_one( + "SELECT count(*) FROM admin_audit_log WHERE admin_user_id IS NULL", + &[], + ) + .await + .unwrap() + .get(0); + assert_eq!(count, 1); + txn.commit().await.unwrap(); +} + +/// The migration CLI cannot erase the origin of a live delegated session. +#[tokio::test] +async fn session_origin_rollback_refuses_live_impersonation() { + const NAME: &str = "2026-10-04-175659_session_origin"; + let db = common::setup_before(NAME, true).await; + assert_eq!(db.run_migrations(Some(1)).await.unwrap(), [NAME]); + let txn = db.begin_transaction().await.unwrap(); + txn.txn() + .execute( + "UPDATE sessions SET origin='impersonation',impersonated_by=$1 WHERE id=$2", + &[ + &*academy_demo::user::ADMIN.user.id, + &*academy_demo::session::FOO_1.id, + ], + ) + .await + .unwrap(); + txn.commit().await.unwrap(); + assert!(db.revert_migrations(Some(1)).await.is_err()); + let txn = db.begin_transaction().await.unwrap(); + let origin: String = txn + .txn() + .query_one( + "SELECT origin FROM sessions WHERE id=$1", + &[&*academy_demo::session::FOO_1.id], + ) + .await + .unwrap() + .get(0); + assert_eq!(origin, "impersonation"); + txn.commit().await.unwrap(); +} diff --git a/academy_persistence/postgres/tests/repos/admin_audit.rs b/academy_persistence/postgres/tests/repos/admin_audit.rs index f5d7d2f0..8ccc7076 100644 --- a/academy_persistence/postgres/tests/repos/admin_audit.rs +++ b/academy_persistence/postgres/tests/repos/admin_audit.rs @@ -20,7 +20,7 @@ fn update_user() -> AdminAuditLogEntry { AdminAuditLogEntry { id: UUID1.into(), at: Utc.with_ymd_and_hms(2026, 9, 3, 12, 0, 0).unwrap(), - admin_user_id: ADMIN.user.id, + admin_user_id: Some(ADMIN.user.id), method: "PATCH".try_into().unwrap(), path: format!("/auth/users/{}", *FOO.user.id).try_into().unwrap(), target_user_id: Some(FOO.user.id), @@ -34,7 +34,7 @@ fn purchase_premium() -> AdminAuditLogEntry { AdminAuditLogEntry { id: UUID2.into(), at: Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(), - admin_user_id: ADMIN.user.id, + admin_user_id: Some(ADMIN.user.id), method: "POST".try_into().unwrap(), path: "/shop/premium".try_into().unwrap(), target_user_id: None, @@ -47,7 +47,7 @@ fn delete_user() -> AdminAuditLogEntry { AdminAuditLogEntry { id: uuid!("f0e7c0f1-7e33-4c19-9a3b-2b2c5d4b8d21").into(), at: Utc.with_ymd_and_hms(2026, 9, 1, 12, 0, 0).unwrap(), - admin_user_id: FOO.user.id, + admin_user_id: Some(FOO.user.id), method: "DELETE".try_into().unwrap(), path: format!("/auth/users/{}", *ADMIN.user.id) .try_into() @@ -214,3 +214,35 @@ async fn survives_user_deletion() { [update_user(), purchase_premium(), delete_user()] ); } + +/// Operator attribution remains explicitly unknown when the repository reads it back. +#[tokio::test] +async fn operator_entry_round_trips_and_does_not_invent_an_admin() { + let db = setup().await; + let entry = AdminAuditLogEntry { + admin_user_id: None, + ..update_user() + }; + let mut txn = db.begin_transaction().await.unwrap(); + REPO.create(&mut txn, &entry).await.unwrap(); + txn.commit().await.unwrap(); + let mut txn = db.begin_transaction().await.unwrap(); + assert_eq!( + REPO.list(&mut txn, AdminAuditLogFilter::default(), make_slice(10, 0)) + .await + .unwrap(), + [entry] + ); + assert_eq!( + REPO.count( + &mut txn, + AdminAuditLogFilter { + admin_user_id: Some(ADMIN.user.id), + ..Default::default() + } + ) + .await + .unwrap(), + 0 + ); +} diff --git a/tests/impersonation.md b/tests/impersonation.md new file mode 100644 index 00000000..534c765e --- /dev/null +++ b/tests/impersonation.md @@ -0,0 +1,32 @@ +# Impersonation provenance and request audit + +`impersonation.py` runs against a real backend binary and owned PostgreSQL, +Valkey, SMTP and OAuth loopback fixtures. It is part of the PostgreSQL CI job. +No external provider or real account is involved. + +A delegated session may update ordinary profile fields. It cannot create, +replace or remove passwords, change the recovery email, link or unlink OAuth, +initialize/enable/disable MFA, or change profile visibility on either the +owner, old opt-out or support route. These guards use current durable origin +in the write transaction for every target. Normal owner operations and support +withdrawal from an administrator's own sign-in remain available. + +Refresh preserves origin. A CLI administrator opening another session passes +on the existing origin, including the unknown operator identity. No descendant +becomes an owner sign-in. + +Before the HTTP handler runs, audit middleware captures durable attribution. +It records the final response status from those captured facts, even after +rotation, self-revocation, account deletion or concurrent revocation. Refresh +uses its submitted refresh credential even without an access bearer. A CLI +operator's bearer still attributes invalid JSON, invalid refresh credentials +and oversized-body refusals. A valid ordinary refresh credential takes priority +over an unrelated bearer and cannot be falsely assigned to that operator. A CLI +operator is recorded with `admin_user_id: null`; the target identifies the +account it acts on. No credentials or request bodies enter audit storage. + +Migration `2026-10-05-180000_operator_audit` makes only the administrator column +nullable. Schema rollback fails if operator entries exist; application rollback +must keep the nullable column and the audit evidence. Old binaries cannot +read operator entries with their former non-nullable decoder. Bind a compatible +recovery binary before deployment. diff --git a/tests/impersonation.py b/tests/impersonation.py new file mode 100644 index 00000000..d8d7fe7e --- /dev/null +++ b/tests/impersonation.py @@ -0,0 +1,344 @@ +"""HTTP/CLI provenance and audit regressions, with owned loopback fixtures only.""" + +import argparse +from concurrent.futures import ThreadPoolExecutor +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import time +from uuid import uuid4 + +REPO = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location("impersonation_sensitive", REPO / "tests/sensitive-writes.py") +sensitive = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = sensitive +spec.loader.exec_module(sensitive) +p = sensitive.publication + + +def setup(f): + owner, admin = f.account(), f.admin() + return owner, admin, p.impersonate(f, admin, owner) + + +def audit(f, owner, method, path, admin=None): + actor = "admin_user_id IS NULL" if admin is None else f"admin_user_id='{admin['id']}'" + return int( + f.sql( + f"SELECT count(*) FROM admin_audit_log WHERE {actor} " + f"AND target_user_id='{owner['id']}' AND method='{method}' AND path='{path}'" + ) + ) + + +def password_change_cannot_create_owner_credentials(f): + owner, admin, delegated = setup(f) + path = "/auth/users/me" + before = f.state(owner, sessions=True) + count = audit(f, owner, "PATCH", path, admin) + f.request( + path, + "PATCH", + {"password": "synthetic replacement password", "display_name": "Must not change"}, + f.token(delegated), + expected=403, + ) + assert f.state(owner, sessions=True) == before + assert audit(f, owner, "PATCH", path, admin) == count + 1 + # The attempted credential cannot be used in a new password sign-in. + f.request( + "/auth/sessions", + "POST", + {"name_or_email": owner["name"], "password": "synthetic replacement password"}, + expected=401, + ) + assert ( + f.sql(f"SELECT count(*) FROM user_profiles WHERE last_shared_receipt IS NOT NULL AND user_id='{owner['id']}'") + == "0" + ) + + +def admin_password_change_and_revocation_remain_audited(f): + admin = f.admin() + path = "/auth/users/me" + count = audit(f, admin, "PATCH", path, admin) + f.request(path, "PATCH", {"password": "synthetic administrator replacement"}, f.token(admin)) + assert f.sql(f"SELECT count(*) FROM sessions WHERE user_id='{admin['id']}'") == "0" + assert audit(f, admin, "PATCH", path, admin) == count + 1 + + +def email_change_cannot_hijack_owner_recovery(f): + owner, _, delegated = setup(f) + before = f.state(owner, sessions=True) + f.request( + "/auth/users/" + owner["id"], + "PATCH", + {"email": "operator-" + uuid4().hex + "@example.com"}, + f.token(delegated), + expected=403, + ) + assert f.state(owner, sessions=True) == before + + +def oauth_link_cannot_create_owner_credentials(f): + owner, _, delegated = setup(f) + callback = f.oauth_callback(delegated) + exchanges = f.oauth.exchanges + f.request("/auth/oauth/links/me", "POST", callback, f.token(delegated), expected=403) + assert f.oauth.exchanges == exchanges # Refused before provider exchange. + assert f.sql(f"SELECT count(*) FROM oauth2_links WHERE user_id='{owner['id']}'") == "0" + # With no link, the operator's identity cannot open an owner session. + flow = f.request("/auth/oauth/authorize", "POST", {"provider_id": "test", "redirect_uri": sensitive.REDIRECT}) + reply = f.request("/auth/sessions/oauth", "POST", {"state": flow["state"], "code": callback["code"]}) + assert reply.get("login") is None + + +def oauth_unlink_cannot_change_owner_credentials(f): + owner, admin = f.account(), f.admin() + link = f.request("/auth/oauth/links/me", "POST", f.oauth_callback(owner), f.token(owner)) + delegated = p.impersonate(f, admin, owner) + before = f.sql(f"SELECT row_to_json(l)::text FROM oauth2_links l WHERE id='{link['id']}'") + f.request("/auth/oauth/links/me/" + link["id"], "DELETE", token=f.token(delegated), expected=403) + assert f.sql(f"SELECT row_to_json(l)::text FROM oauth2_links l WHERE id='{link['id']}'") == before + + +def mfa_initialization_refused(f): + owner, _, delegated = setup(f) + f.request("/auth/users/me/mfa", "POST", token=f.token(delegated), expected=403) + assert f.sql(f"SELECT count(*) FROM totp_devices WHERE user_id='{owner['id']}'") == "0" + + +def mfa_enable_refused(f): + owner, admin = f.account(), f.admin() + secret = f.request("/auth/users/me/mfa", "POST", token=f.token(owner)) + delegated = p.impersonate(f, admin, owner) + f.request("/auth/users/me/mfa", "PUT", {"code": sensitive.safety.totp(secret)}, f.token(delegated), expected=403) + assert f.sql(f"SELECT count(*) FROM totp_devices WHERE user_id='{owner['id']}' AND enabled") == "0" + + +def mfa_disable_refused(f): + owner, admin = f.account(), f.admin() + secret = f.request("/auth/users/me/mfa", "POST", token=f.token(owner)) + f.request("/auth/users/me/mfa", "PUT", {"code": sensitive.safety.totp(secret)}, f.token(owner)) + delegated = p.impersonate(f, admin, owner) + f.request("/auth/users/me/mfa", "DELETE", token=f.token(delegated), expected=403) + assert f.sql(f"SELECT count(*) FROM totp_devices WHERE user_id='{owner['id']}' AND enabled") == "1" + + +def revoked_self_session_remains_audited(f): + owner, admin, delegated = setup(f) + path = "/auth/sessions/me/" + delegated["session"]["id"] + count = audit(f, owner, "DELETE", path, admin) + f.request(path, "DELETE", token=f.token(delegated)) + assert f.sql(f"SELECT count(*) FROM sessions WHERE id='{delegated['session']['id']}'") == "0" + assert audit(f, owner, "DELETE", path, admin) == count + 1 + + +def revoked_all_sessions_remain_audited(f): + owner, admin, delegated = setup(f) + path = "/auth/sessions/me" + count = audit(f, owner, "DELETE", path, admin) + f.request(path, "DELETE", token=f.token(delegated)) + assert f.sql(f"SELECT count(*) FROM sessions WHERE user_id='{owner['id']}'") == "0" + assert audit(f, owner, "DELETE", path, admin) == count + 1 + + +def refresh_audit(f, with_bearer): + owner, admin, delegated = setup(f) + count = audit(f, owner, "PUT", "/auth/session", admin) + if with_bearer: + for body, status in [ + ({}, 422), + ({"refresh_token": "synthetic invalid refresh"}, 401), + ({"padding": "x" * (2 * 1024 * 1024)}, 413), + ]: + f.request("/auth/session", "PUT", body, f.token(delegated), expected=status) + count += 1 + assert audit(f, owner, "PUT", "/auth/session", admin) == count + assert f.sql( + f"SELECT status FROM admin_audit_log WHERE admin_user_id='{admin['id']}' " + f"AND target_user_id='{owner['id']}' AND path='/auth/session' ORDER BY at DESC LIMIT 1" + ) == str(status) + renewed = f.request( + "/auth/session", + "PUT", + {"refresh_token": delegated["refresh_token"]}, + f.token(delegated) if with_bearer else None, + ) + assert renewed["session"]["id"] == delegated["session"]["id"] + assert audit(f, owner, "PUT", "/auth/session", admin) == count + 1 + assert ( + f.sql(f"SELECT origin||'|'||impersonated_by FROM sessions WHERE id='{renewed['session']['id']}'") + == "impersonation|" + admin["id"] + ) + f.request("/auth/session", token=f.token(delegated), expected=401) + _, choice = f.preview_choice(renewed) + assert f.publication(method="PUT", body=choice, token=f.token(renewed), expected=403) == p.REFUSED + + +def refreshed_bearer_remains_audited(f): + refresh_audit(f, True) + + +def refresh_without_bearer_remains_audited(f): + refresh_audit(f, False) + + +def refresh_uses_actual_credential_instead_of_unrelated_bearer(f): + owner, admin, delegated = setup(f) + other = f.account() + count = audit(f, owner, "PUT", "/auth/session", admin) + renewed_other = f.request("/auth/session", "PUT", {"refresh_token": other["refresh_token"]}, f.token(delegated)) + assert audit(f, owner, "PUT", "/auth/session", admin) == count + assert f.sql(f"SELECT origin FROM sessions WHERE id='{renewed_other['session']['id']}'") == "sign_in" + renewed = f.request("/auth/session", "PUT", {"refresh_token": delegated["refresh_token"]}, f.token(renewed_other)) + assert audit(f, owner, "PUT", "/auth/session", admin) == count + 1 + assert f.sql(f"SELECT origin FROM sessions WHERE id='{renewed['session']['id']}'") == "impersonation" + + +def account_deletion_remains_audited(f): + owner, admin, delegated = setup(f) + path = "/auth/users/me" + count = audit(f, owner, "DELETE", path, admin) + f.request(path, "DELETE", token=f.token(delegated)) + assert f.sql(f"SELECT count(*) FROM users WHERE id='{owner['id']}'") == "0" + assert audit(f, owner, "DELETE", path, admin) == count + 1 + + +def cli_support_and_foreign_legacy_withdrawals_refused(f): + admin, owner = f.admin(), f.account() + for account in (admin, owner): + _, choice = f.preview_choice(account) + f.publication(method="PUT", body=choice, token=f.token(account)) + bearer = f.command(f.args.binary, "admin", "session", "impersonate", admin["name"]) + for account in (admin, owner): + before = f.sql(p.PROFILE_ROW.format(account["id"])) + revision = f.publication(token=f.token(account))["visibility_revision"] + f.publication( + "/auth/admin/users/" + account["id"] + "/publication/withdraw", + method="POST", + body={"expected_revision": revision, "request_id": str(uuid4())}, + token=bearer, + expected=403, + ) + f.request("/auth/users/" + account["id"], "PATCH", {"leaderboard_opt_out": True}, bearer, expected=403) + assert f.sql(p.PROFILE_ROW.format(account["id"])) == before + # A normal MFA-confirmed admin retains the support withdrawal. + revision = f.publication(token=f.token(owner))["visibility_revision"] + reply = f.publication( + "/auth/admin/users/" + owner["id"] + "/publication/withdraw", + method="POST", + body={"expected_revision": revision, "request_id": str(uuid4())}, + token=f.token(admin), + ) + assert reply["receipt"]["source"] == "support" + + +def cli_requests_and_descendants_remain_audited(f): + admin, owner = f.admin(), f.account() + bearer = f.command(f.args.binary, "admin", "session", "impersonate", admin["name"]) + path = "/auth/users/" + owner["id"] + count = audit(f, owner, "PATCH", path) + f.request(path, "PATCH", {"display_name": "Synthetic CLI update"}, bearer) + assert audit(f, owner, "PATCH", path) == count + 1 + descendant = f.request("/auth/sessions/" + owner["id"], "POST", token=bearer) + assert ( + f.sql( + f"SELECT origin||'|'||(impersonated_by IS NULL)::text FROM sessions WHERE id='{descendant['session']['id']}'" + ) + == "impersonation|true" + ) + count = audit(f, owner, "PUT", "/auth/session") + renewed = f.request("/auth/session", "PUT", {"refresh_token": descendant["refresh_token"]}) + assert audit(f, owner, "PUT", "/auth/session") == count + 1 + _, choice = f.preview_choice(renewed) + f.publication(method="PUT", body=choice, token=f.token(renewed), expected=403) + assert audit(f, owner, "PUT", p.OWNER) == 1 + + +def concurrent_revocation_keeps_captured_actor(f): + target, actor = sorted((f.admin(), f.admin()), key=lambda user: user["id"]) + bearer = f.command(f.args.binary, "admin", "session", "impersonate", actor["name"]) + session = f.request("/auth/session", token=bearer) + path = "/auth/users/" + target["id"] + count = audit(f, target, "PATCH", path) + blocker = sensitive.OwnerLock(f, target) + try: + with ThreadPoolExecutor(max_workers=1) as pool: + pending = pool.submit(f.request, path, "PATCH", {"display_name": "Must not change"}, bearer, None) + assert sensitive.wait_blocked(f, pending) + f.request("/auth/sessions/me/" + session["id"], "DELETE", token=f.token(actor)) + blocker.release() + assert pending.result(timeout=25)[0] == 401 + assert audit(f, target, "PATCH", path) == count + 1 + finally: + blocker.close() + + +CASES = [ + password_change_cannot_create_owner_credentials, + admin_password_change_and_revocation_remain_audited, + email_change_cannot_hijack_owner_recovery, + oauth_link_cannot_create_owner_credentials, + oauth_unlink_cannot_change_owner_credentials, + mfa_initialization_refused, + mfa_enable_refused, + mfa_disable_refused, + revoked_self_session_remains_audited, + revoked_all_sessions_remain_audited, + refreshed_bearer_remains_audited, + refresh_without_bearer_remains_audited, + refresh_uses_actual_credential_instead_of_unrelated_bearer, + account_deletion_remains_audited, + cli_support_and_foreign_legacy_withdrawals_refused, + cli_requests_and_descendants_remain_audited, + concurrent_revocation_keeps_captured_actor, +] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--pg-bin", type=Path, required=True) + parser.add_argument("--valkey", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if os.geteuid() == 0: + parser.error("run as an unprivileged user") + for name in ("binary", "pg_bin", "valkey"): + setattr(args, name, getattr(args, name).resolve()) + args.output.mkdir(parents=True, exist_ok=False) + result = { + "passed": False, + "cases": [], + "commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=REPO, text=True).strip(), + "scenario_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "binary_sha256": p.safety.binary_digest(args.binary), + } + try: + with sensitive.Fixture(args) as f: + f.activate() + for case in CASES: + started = time.monotonic() + try: + case(f) + row = {"name": case.__name__, "passed": True} + except Exception as error: + row = {"name": case.__name__, "passed": False, "error": str(error)} + row["seconds"] = round(time.monotonic() - started, 3) + result["cases"].append(row) + (args.output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print(case.__name__ + ": " + str(row["passed"]), flush=True) + result["passed"] = all(case["passed"] for case in result["cases"]) + finally: + result["fixture_removed"] = not f.base.exists() if "f" in locals() else None + (args.output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main())