1use std::future::Future;
18use std::sync::Arc;
19
20use acme_proxy_core::audit::{Actor, AuditRecord, ClientContext};
21use acme_proxy_jobs::auditor::admin as audit;
22use acme_proxy_jobs::auditor::admin::SessionScope;
23use acme_proxy_jobs::notify::AdminCredentialChange;
24use acme_proxy_store::admin_user::{AdminRole, AdminStatus, AdminUser};
25use acme_proxy_store::db::Database;
26
27use crate::admin::mfa;
28use crate::admin::users::{self, UserError};
29
30pub trait OperatorTrail: Sync {
35 fn record(
37 &self,
38 build: impl FnOnce(Actor, ClientContext) -> AuditRecord + Send,
39 ) -> impl Future<Output = ()> + Send;
40
41 fn notify(
44 &self,
45 user: &AdminUser,
46 change: AdminCredentialChange,
47 previous_recipient: Option<String>,
48 ) -> impl Future<Output = ()> + Send;
49}
50
51pub async fn change_status(
55 username: &str,
56 status: AdminStatus,
57 database: Arc<Database>,
58 trail: &impl OperatorTrail,
59) -> Result<Option<(AdminUser, u64)>, sqlx::Error> {
60 let Some((user, revoked)) = users::set_status(username, status, database).await? else {
61 return Ok(None);
62 };
63 let active = status == AdminStatus::Active;
64 trail
65 .record(|actor, client| {
66 audit::operator_status_changed(actor, client, &user.username, active)
67 })
68 .await;
69 record_revoked(&user, revoked, trail).await;
70 Ok(Some((user, revoked)))
71}
72
73pub async fn change_role(
77 username: &str,
78 role: AdminRole,
79 database: Arc<Database>,
80 trail: &impl OperatorTrail,
81) -> Result<Option<(AdminUser, u64)>, UserError> {
82 let Some((user, revoked)) = users::set_role(username, role, database).await? else {
83 return Ok(None);
84 };
85 trail
86 .record(|actor, client| {
87 audit::operator_role_changed(actor, client, &user.username, role.as_str())
88 })
89 .await;
90 record_revoked(&user, revoked, trail).await;
91 Ok(Some((user, revoked)))
92}
93
94pub async fn change_contact(
103 username: &str,
104 contact: Option<&str>,
105 database: Arc<Database>,
106 trail: &impl OperatorTrail,
107) -> Result<Option<(AdminUser, bool)>, UserError> {
108 let previous = AdminUser::find_by_username(username, &database)
109 .await?
110 .and_then(|user| user.contact_email);
111 let Some(user) = users::set_contact_email(username, contact, database).await? else {
112 return Ok(None);
113 };
114 if user.contact_email == previous {
115 return Ok(Some((user, false)));
116 }
117 let set = user.contact_email.is_some();
118 trail
119 .record(|actor, client| audit::operator_contact_updated(actor, client, &user.username, set))
120 .await;
121 trail
122 .notify(&user, AdminCredentialChange::ContactAddress, previous)
123 .await;
124 Ok(Some((user, true)))
125}
126
127pub async fn reset_totp(
132 user: &mut AdminUser,
133 database: Arc<Database>,
134 trail: &impl OperatorTrail,
135) -> Result<(), sqlx::Error> {
136 mfa::disable_totp(user, None, database).await?;
137 trail
138 .record(|actor, client| audit::operator_totp_disabled(actor, client, &user.username, true))
139 .await;
140 trail
141 .notify(user, AdminCredentialChange::SecondFactorDisabled, None)
142 .await;
143 Ok(())
144}
145
146async fn record_revoked(user: &AdminUser, revoked: u64, trail: &impl OperatorTrail) {
147 if revoked > 0 {
148 trail
149 .record(|actor, client| {
150 audit::session_revoked(
151 actor,
152 client,
153 SessionScope::AllOf(user.username.clone()),
154 revoked,
155 )
156 })
157 .await;
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use acme_proxy_store::admin_session::{AdminSession, NewSession};
165 use std::sync::Mutex;
166
167 #[derive(Default)]
170 struct Recording {
171 events: Mutex<Vec<String>>,
172 messages: Mutex<Vec<(AdminCredentialChange, Option<String>)>>,
173 }
174
175 impl OperatorTrail for Recording {
176 async fn record(&self, build: impl FnOnce(Actor, ClientContext) -> AuditRecord + Send) {
177 let record = build(Actor::cli(), ClientContext::default());
178 self.events
179 .lock()
180 .unwrap()
181 .push(record.event.as_str().to_string());
182 }
183
184 async fn notify(
185 &self,
186 _user: &AdminUser,
187 change: AdminCredentialChange,
188 previous_recipient: Option<String>,
189 ) {
190 self.messages
191 .lock()
192 .unwrap()
193 .push((change, previous_recipient));
194 }
195 }
196
197 impl Recording {
198 fn events(&self) -> Vec<String> {
199 self.events.lock().unwrap().clone()
200 }
201 }
202
203 async fn db() -> Arc<Database> {
204 Arc::new(Database::connect_in_memory().await.unwrap())
205 }
206
207 async fn operator(username: &str, database: &Database) -> AdminUser {
209 AdminUser::create(username, "unused", Some(AdminRole::Admin), database)
210 .await
211 .unwrap()
212 }
213
214 async fn session_for(user: &AdminUser, database: &Database) {
215 AdminSession::create(
216 NewSession {
217 user_id: user.id,
218 token_hash: "hash",
219 csrf_token: "csrf",
220 created_ip: None,
221 user_agent: None,
222 },
223 std::time::Duration::from_secs(60),
224 database,
225 )
226 .await
227 .unwrap();
228 }
229
230 #[tokio::test]
233 async fn a_status_change_records_the_sessions_it_ended_and_only_those() {
234 let database = db().await;
235 let alice = operator("alice", &database).await;
236 operator("root", &database).await;
237
238 let trail = Recording::default();
239 change_status("alice", AdminStatus::Disabled, database.clone(), &trail)
240 .await
241 .unwrap()
242 .unwrap();
243 assert_eq!(trail.events(), ["operator_disabled"]);
244
245 change_status("alice", AdminStatus::Active, database.clone(), &trail)
246 .await
247 .unwrap();
248 session_for(&alice, &database).await;
249 let trail = Recording::default();
250 let (_, revoked) = change_role("alice", AdminRole::Viewer, database.clone(), &trail)
251 .await
252 .unwrap()
253 .unwrap();
254 assert_eq!(revoked, 1);
255 assert_eq!(trail.events(), ["operator_role_changed", "session_revoked"]);
256
257 assert!(
258 change_status("nobody", AdminStatus::Disabled, database, &trail)
259 .await
260 .unwrap()
261 .is_none()
262 );
263 }
264
265 #[tokio::test]
268 async fn a_contact_change_records_and_notifies_only_a_real_change() {
269 let database = db().await;
270 operator("alice", &database).await;
271
272 let trail = Recording::default();
273 change_contact("alice", Some("a@example.com"), database.clone(), &trail)
274 .await
275 .unwrap();
276 let trail = Recording::default();
277 let (_, changed) = change_contact("alice", Some("a@example.com"), database.clone(), &trail)
278 .await
279 .unwrap()
280 .unwrap();
281 assert!(!changed);
282 assert!(trail.events().is_empty());
283 assert!(trail.messages.lock().unwrap().is_empty());
284
285 let (_, changed) = change_contact("alice", Some("b@example.com"), database, &trail)
286 .await
287 .unwrap()
288 .unwrap();
289 assert!(changed);
290 assert_eq!(trail.events(), ["operator_contact_updated"]);
291 assert_eq!(
292 *trail.messages.lock().unwrap(),
293 [(
294 AdminCredentialChange::ContactAddress,
295 Some("a@example.com".to_string())
296 )]
297 );
298 }
299
300 #[tokio::test]
301 async fn a_totp_reset_is_recorded_and_the_operator_told() {
302 let database = db().await;
303 let mut alice = operator("alice", &database).await;
304
305 let trail = Recording::default();
306 reset_totp(&mut alice, database, &trail).await.unwrap();
307 assert_eq!(trail.events(), ["operator_totp_disabled"]);
308 assert_eq!(
309 *trail.messages.lock().unwrap(),
310 [(AdminCredentialChange::SecondFactorDisabled, None)]
311 );
312 }
313}