use std::future::Future;
use std::sync::Arc;
use acme_proxy_core::audit::{Actor, AuditRecord, ClientContext};
use acme_proxy_jobs::auditor::admin as audit;
use acme_proxy_jobs::auditor::admin::SessionScope;
use acme_proxy_jobs::notify::AdminCredentialChange;
use acme_proxy_store::admin_user::{AdminRole, AdminStatus, AdminUser};
use acme_proxy_store::db::Database;
use crate::admin::mfa;
use crate::admin::users::{self, UserError};
pub trait OperatorTrail: Sync {
fn record(
&self,
build: impl FnOnce(Actor, ClientContext) -> AuditRecord + Send,
) -> impl Future<Output = ()> + Send;
fn notify(
&self,
user: &AdminUser,
change: AdminCredentialChange,
previous_recipient: Option<String>,
) -> impl Future<Output = ()> + Send;
}
pub async fn change_status(
username: &str,
status: AdminStatus,
database: Arc<Database>,
trail: &impl OperatorTrail,
) -> Result<Option<(AdminUser, u64)>, sqlx::Error> {
let Some((user, revoked)) = users::set_status(username, status, database).await? else {
return Ok(None);
};
let active = status == AdminStatus::Active;
trail
.record(|actor, client| {
audit::operator_status_changed(actor, client, &user.username, active)
})
.await;
record_revoked(&user, revoked, trail).await;
Ok(Some((user, revoked)))
}
pub async fn change_role(
username: &str,
role: AdminRole,
database: Arc<Database>,
trail: &impl OperatorTrail,
) -> Result<Option<(AdminUser, u64)>, UserError> {
let Some((user, revoked)) = users::set_role(username, role, database).await? else {
return Ok(None);
};
trail
.record(|actor, client| {
audit::operator_role_changed(actor, client, &user.username, role.as_str())
})
.await;
record_revoked(&user, revoked, trail).await;
Ok(Some((user, revoked)))
}
pub async fn change_contact(
username: &str,
contact: Option<&str>,
database: Arc<Database>,
trail: &impl OperatorTrail,
) -> Result<Option<(AdminUser, bool)>, UserError> {
let previous = AdminUser::find_by_username(username, &database)
.await?
.and_then(|user| user.contact_email);
let Some(user) = users::set_contact_email(username, contact, database).await? else {
return Ok(None);
};
if user.contact_email == previous {
return Ok(Some((user, false)));
}
let set = user.contact_email.is_some();
trail
.record(|actor, client| audit::operator_contact_updated(actor, client, &user.username, set))
.await;
trail
.notify(&user, AdminCredentialChange::ContactAddress, previous)
.await;
Ok(Some((user, true)))
}
pub async fn reset_totp(
user: &mut AdminUser,
database: Arc<Database>,
trail: &impl OperatorTrail,
) -> Result<(), sqlx::Error> {
mfa::disable_totp(user, None, database).await?;
trail
.record(|actor, client| audit::operator_totp_disabled(actor, client, &user.username, true))
.await;
trail
.notify(user, AdminCredentialChange::SecondFactorDisabled, None)
.await;
Ok(())
}
async fn record_revoked(user: &AdminUser, revoked: u64, trail: &impl OperatorTrail) {
if revoked > 0 {
trail
.record(|actor, client| {
audit::session_revoked(
actor,
client,
SessionScope::AllOf(user.username.clone()),
revoked,
)
})
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use acme_proxy_store::admin_session::{AdminSession, NewSession};
use std::sync::Mutex;
#[derive(Default)]
struct Recording {
events: Mutex<Vec<String>>,
messages: Mutex<Vec<(AdminCredentialChange, Option<String>)>>,
}
impl OperatorTrail for Recording {
async fn record(&self, build: impl FnOnce(Actor, ClientContext) -> AuditRecord + Send) {
let record = build(Actor::cli(), ClientContext::default());
self.events
.lock()
.unwrap()
.push(record.event.as_str().to_string());
}
async fn notify(
&self,
_user: &AdminUser,
change: AdminCredentialChange,
previous_recipient: Option<String>,
) {
self.messages
.lock()
.unwrap()
.push((change, previous_recipient));
}
}
impl Recording {
fn events(&self) -> Vec<String> {
self.events.lock().unwrap().clone()
}
}
async fn db() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
async fn operator(username: &str, database: &Database) -> AdminUser {
AdminUser::create(username, "unused", Some(AdminRole::Admin), database)
.await
.unwrap()
}
async fn session_for(user: &AdminUser, database: &Database) {
AdminSession::create(
NewSession {
user_id: user.id,
token_hash: "hash",
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(60),
database,
)
.await
.unwrap();
}
#[tokio::test]
async fn a_status_change_records_the_sessions_it_ended_and_only_those() {
let database = db().await;
let alice = operator("alice", &database).await;
operator("root", &database).await;
let trail = Recording::default();
change_status("alice", AdminStatus::Disabled, database.clone(), &trail)
.await
.unwrap()
.unwrap();
assert_eq!(trail.events(), ["operator_disabled"]);
change_status("alice", AdminStatus::Active, database.clone(), &trail)
.await
.unwrap();
session_for(&alice, &database).await;
let trail = Recording::default();
let (_, revoked) = change_role("alice", AdminRole::Viewer, database.clone(), &trail)
.await
.unwrap()
.unwrap();
assert_eq!(revoked, 1);
assert_eq!(trail.events(), ["operator_role_changed", "session_revoked"]);
assert!(
change_status("nobody", AdminStatus::Disabled, database, &trail)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn a_contact_change_records_and_notifies_only_a_real_change() {
let database = db().await;
operator("alice", &database).await;
let trail = Recording::default();
change_contact("alice", Some("a@example.com"), database.clone(), &trail)
.await
.unwrap();
let trail = Recording::default();
let (_, changed) = change_contact("alice", Some("a@example.com"), database.clone(), &trail)
.await
.unwrap()
.unwrap();
assert!(!changed);
assert!(trail.events().is_empty());
assert!(trail.messages.lock().unwrap().is_empty());
let (_, changed) = change_contact("alice", Some("b@example.com"), database, &trail)
.await
.unwrap()
.unwrap();
assert!(changed);
assert_eq!(trail.events(), ["operator_contact_updated"]);
assert_eq!(
*trail.messages.lock().unwrap(),
[(
AdminCredentialChange::ContactAddress,
Some("a@example.com".to_string())
)]
);
}
#[tokio::test]
async fn a_totp_reset_is_recorded_and_the_operator_told() {
let database = db().await;
let mut alice = operator("alice", &database).await;
let trail = Recording::default();
reset_totp(&mut alice, database, &trail).await.unwrap();
assert_eq!(trail.events(), ["operator_totp_disabled"]);
assert_eq!(
*trail.messages.lock().unwrap(),
[(AdminCredentialChange::SecondFactorDisabled, None)]
);
}
}