Skip to main content

acme_proxy_admin/admin/
changes.rs

1//! A change to an operator — by another operator, or, for the contact
2//! address, by themselves: each change with everything it owes — the write,
3//! the audit rows, the message — in one function both front ends call.
4//!
5//! The CLI and the web admin each used to spell these four as "apply → audit →
6//! revoked-sessions row → notify", and the copies had drifted twice over: the
7//! CLI wrote a `session_revoked` row counting zero sessions after a role or
8//! password change, and an `operator_contact_updated` row for an address set to
9//! what it already was, where the panel wrote neither. What genuinely differs
10//! between the surfaces — who the actor is, how the client is resolved, which
11//! dispatcher carries the message — is an [`OperatorTrail`]; the sequence is
12//! here, once.
13//!
14//! Log lines stay with each front end: they name the surface and the signed-in
15//! operator, which only the front end knows.
16
17use 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
30/// Where one surface's record of an operator change goes.
31///
32/// The futures are `Send` because the web admin awaits them inside an axum
33/// handler.
34pub trait OperatorTrail: Sync {
35    /// Writes one audit row, built from this surface's actor and client.
36    fn record(
37        &self,
38        build: impl FnOnce(Actor, ClientContext) -> AuditRecord + Send,
39    ) -> impl Future<Output = ()> + Send;
40
41    /// Queues the `admin_credential_changed` message to the operator `user`.
42    /// `previous_recipient` is the address a contact change replaced.
43    fn notify(
44        &self,
45        user: &AdminUser,
46        change: AdminCredentialChange,
47        previous_recipient: Option<String>,
48    ) -> impl Future<Output = ()> + Send;
49}
50
51/// Enables or disables `username`. Disabling ends every session they held,
52/// which is a second thing that happened and so a second row — only when
53/// there was a session to end. `None` when there is no such operator.
54pub 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
73/// Sets `username`'s tier, ending their sessions so the new one applies at
74/// once — recorded as for [`change_status`]. `None` when there is no such
75/// operator.
76pub 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
94/// Sets or clears `username`'s notification address.
95///
96/// An address set to what it already was writes no row and sends no message:
97/// telling somebody their alarms moved to the address they were already using
98/// is noise that teaches them to ignore the real one. Otherwise the message
99/// goes to the address the change **replaced** — whoever made the change
100/// controls the new one. The `bool` is whether anything changed; `None` when
101/// there is no such operator.
102pub 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
127/// Removes `user`'s second factor and every recovery code on another
128/// operator's say-so, ending their sessions: there is no session of theirs to
129/// keep, the change being made from somewhere they are not signed in. They are
130/// told, since they did not do it.
131pub 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    /// A trail that keeps what it was given: the event of each row, and the
168    /// change and previous recipient of each message.
169    #[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    /// An admin with a placeholder hash: nothing here signs in.
208    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    /// The sessions row only when there were sessions — the drift this module
231    /// was extracted to end.
232    #[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    /// An address set to what it already was writes nothing and tells nobody;
266    /// a real change is one row and one message, to the address it replaced.
267    #[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}