Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 12 additions & 3 deletions academy/src/commands/admin/session.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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")?;

Expand Down
4 changes: 3 additions & 1 deletion academy/src/commands/migrate/load/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion academy/src/environment/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ pub type UserFeature = UserFeatureServiceImpl<
UserRepo,
CoinRepo,
DocumentRepo,
SessionRepo,
>;
pub type User = UserServiceImpl<Id, Time, Password, UserRepo, OAuth2Link>;
pub type UserEmailConfirmation =
Expand Down Expand Up @@ -225,6 +226,7 @@ pub type MfaFeature = MfaFeatureServiceImpl<
MfaRecovery,
MfaDisable,
MfaTotpDevice,
SessionRepo,
>;
pub type MfaRecovery = MfaRecoveryServiceImpl<Secret, Hash, MfaRepo>;
pub type MfaAuthenticate = MfaAuthenticateServiceImpl<Hash, Totp, MfaDisable, MfaRepo>;
Expand All @@ -241,6 +243,7 @@ pub type OAuth2Feature = OAuth2FeatureServiceImpl<
OAuth2Login,
OAuth2Registration,
Session,
SessionRepo,
>;
pub type OAuth2Link = OAuth2LinkServiceImpl<Id, Time, OAuth2Repo>;
pub type OAuth2Authorization = OAuth2AuthorizationServiceImpl<Secret, Cache, OAuth2Api>;
Expand Down Expand Up @@ -314,7 +317,8 @@ pub type Internal = InternalServiceImpl<
HeartRepo,
>;

pub type AdminAuditFeature = AdminAuditFeatureServiceImpl<Database, Auth, Id, Time, AdminAuditRepo>;
pub type AdminAuditFeature =
AdminAuditFeatureServiceImpl<Database, Auth, Id, Time, AdminAuditRepo, SessionRepo, UserRepo>;

pub type PurchaseRepo = academy_persistence_postgres::purchase::PostgresPurchaseRepository;
pub type PurchaseFeature = academy_core_purchase_impl::PurchaseFeatureServiceImpl<
Expand Down Expand Up @@ -357,4 +361,5 @@ pub type PublicationFeature = academy_core_user_impl::publication::PublicationFe
Jwt,
PublicationRepo,
UserRepo,
SessionRepo,
>;
11 changes: 9 additions & 2 deletions academy/tests/auth_robustness_native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions academy_api/rest/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -39,6 +42,7 @@ pub fn auth_error_docs(op: TransformOperation) -> TransformOperation {
.with(internal_server_error_docs)
.add_error::<PermissionDeniedError>()
.add_error::<AdminMfaRequiredError>()
.add_error::<OwnerSignInRequiredError>()
.add_error::<EmailNotVerifiedError>()
}

Expand Down Expand Up @@ -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");

Expand Down
84 changes: 74 additions & 10 deletions academy_api/rest/src/middlewares/admin_audit.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -76,7 +76,71 @@ async fn middleware(
.get::<RequestId>()
.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::<crate::routes::session::RefreshRequest>(&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.
Expand All @@ -87,7 +151,7 @@ async fn middleware(

if let Err(err) = service
.record(AdminAuditRequest {
token,
actor,
method,
path,
route,
Expand Down
3 changes: 2 additions & 1 deletion academy_api/rest/src/models/admin_audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserId>,
/// HTTP method of the request
pub method: RequestMethod,
/// Path of the request, without the query string
Expand Down
20 changes: 20 additions & 0 deletions academy_api/rest/src/routes/publication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Serialize>(result: Result<T, PublicationError>) -> Response {
let error = match result {
Ok(value) => return Json(value).into_response(),
Expand All @@ -92,6 +105,7 @@ fn respond<T: Serialize>(result: Result<T, PublicationError>) -> 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()
}
Expand Down Expand Up @@ -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::<PublicationChoiceResult>(StatusCode::OK, None)
}
fn epoch_docs(op: TransformOperation) -> TransformOperation {
Expand Down
4 changes: 2 additions & 2 deletions academy_api/rest/src/routes/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions academy_api/rest/src/routes/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand All @@ -372,6 +373,7 @@ fn update_docs(op: TransformOperation) -> TransformOperation {
.add_error::<EmailAlreadyExistsError>()
.add_error::<CannotDeleteLastLoginMethodError>()
.add_error::<PermissionDeniedError>()
.add_error::<OwnerSignInRequiredError>()
.add_error::<InvalidVatIdError>()
.with(auth_error_docs)
.with(internal_server_error_docs)
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading