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/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..cbf1dedf 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 = @@ -225,6 +226,7 @@ pub type MfaFeature = MfaFeatureServiceImpl< MfaRecovery, MfaDisable, MfaTotpDevice, + SessionRepo, >; pub type MfaRecovery = MfaRecoveryServiceImpl; pub type MfaAuthenticate = MfaAuthenticateServiceImpl; @@ -241,6 +243,7 @@ pub type OAuth2Feature = OAuth2FeatureServiceImpl< OAuth2Login, OAuth2Registration, Session, + SessionRepo, >; pub type OAuth2Link = OAuth2LinkServiceImpl; pub type OAuth2Authorization = OAuth2AuthorizationServiceImpl; @@ -314,7 +317,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 +361,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/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 71f39b8a..3313d240 100644 --- a/academy_api/rest/src/middlewares/admin_audit.rs +++ b/academy_api/rest/src/middlewares/admin_audit.rs @@ -1,20 +1,24 @@ //! 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; -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; @@ -59,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. @@ -76,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. @@ -87,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 4ce01f59..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(), @@ -92,6 +105,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 +179,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/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_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..f378d907 100644 --- a/academy_core/admin_audit/contracts/src/lib.rs +++ b/academy_core/admin_audit/contracts/src/lib.rs @@ -2,17 +2,25 @@ 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 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. Attribution has already + /// been captured; recording never authenticates the credential again. + /// Returns whether an entry has been recorded. fn record( &self, request: AdminAuditRequest, @@ -28,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 34a5f58b..3061914a 100644 --- a/academy_core/admin_audit/impl/src/lib.rs +++ b/academy_core/admin_audit/impl/src/lib.rs @@ -1,11 +1,19 @@ -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_persistence_contracts::{Database, Transaction, admin_audit::AdminAuditRepository}; +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; use anyhow::Context; @@ -15,55 +23,144 @@ 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, Id: IdService, 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?; + 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, 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) + } - // 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); - } + #[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), + 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(), 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..9d7d9560 100644 --- a/academy_core/admin_audit/impl/src/tests/mod.rs +++ b/academy_core/admin_audit/impl/src/tests/mod.rs @@ -1,9 +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, user::MockUserRepository, }; use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; use chrono::{DateTime, TimeZone, Utc}; @@ -19,6 +20,8 @@ type Sut = AdminAuditFeatureServiceImpl< MockIdService, MockTimeService, MockAdminAuditRepository, + MockSessionRepository, + MockUserRepository, >; fn now() -> DateTime { @@ -27,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() @@ -43,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 17724f86..c4ba2168 100644 --- a/academy_core/admin_audit/impl/src/tests/record.rs +++ b/academy_core/admin_audit/impl/src/tests/record.rs @@ -1,112 +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::{ 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::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), - } -} - -#[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()); +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::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 e8847a1a..7494c75b 100644 --- a/academy_core/oauth2/impl/src/lib.rs +++ b/academy_core/oauth2/impl/src/lib.rs @@ -18,12 +18,15 @@ use academy_models::{ OAuth2Provider, OAuth2ProviderId, OAuth2ProviderSummary, OAuth2Registration, OAuth2UserInfo, }, - session::DeviceName, + session::{DeviceName, SessionOrigin}, url::Url, 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 @@ -423,7 +433,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_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/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/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/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..8a268a18 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")?; @@ -214,11 +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) + .create(&mut txn, user_composite, None, false, false, origin) .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..3eccdc86 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,14 +36,24 @@ 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, 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/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..e12c8914 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); } + // 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? + .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..5e93e559 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, @@ -110,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? @@ -201,6 +210,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/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_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..f8ce87d0 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,8 +21,10 @@ pub struct AdminAuditLogEntry { pub id: AdminAuditLogEntryId, /// Time at which the request was answered pub at: DateTime, - /// The administrator whose access token authenticated the request - pub admin_user_id: UserId, + /// The administrator whose access token authenticated the request, or who + /// opened the session in someone else's account it was made in + /// 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_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/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/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..57d3ba09 --- /dev/null +++ b/academy_persistence/postgres/migrations/2026-10-04-175659_session_origin/down.sql @@ -0,0 +1,15 @@ +-- 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. +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/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/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/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/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..334aaa25 100644 --- a/academy_persistence/postgres/tests/invoice_identity.rs +++ b/academy_persistence/postgres/tests/invoice_identity.rs @@ -142,7 +142,9 @@ 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", + "2026-10-05-180000_operator_audit" ] ); } @@ -552,7 +554,9 @@ 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", + "2026-10-05-180000_operator_audit" ] ); } @@ -705,7 +709,9 @@ 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", + "2026-10-05-180000_operator_audit" ] ); } diff --git a/academy_persistence/postgres/tests/migrations.rs b/academy_persistence/postgres/tests/migrations.rs index 72d73b8a..e07c42ed 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 { @@ -132,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/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/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()) 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,