Skip to main content

acme_proxy/cli/
account.rs

1//! `acme-proxy account` — list, show, update the contacts of, deactivate and
2//! delete ACME accounts.
3//!
4//! Each write goes through `admin::ops`, the same operation the web admin runs,
5//! and records an audit row. `delete` is refused while any of the account's
6//! orders holds a live certificate, since the order row is the certificate's
7//! only record.
8
9use std::io::BufRead;
10use std::sync::Arc;
11
12use clap::Subcommand;
13
14use crate::cli::CliError;
15use crate::cli::render;
16use crate::cli::window::{DEFAULT_LIMIT, Window};
17use acme_proxy_admin::admin;
18use acme_proxy_admin::admin::DeleteOutcome;
19use acme_proxy_core::config::Config;
20use acme_proxy_core::palette::Palette;
21use acme_proxy_jobs::auditor::admin as audit_admin;
22use acme_proxy_store::account::Account;
23use acme_proxy_store::db::Database;
24
25#[derive(Subcommand)]
26pub enum AccountCommand {
27    /// List accounts, newest first, of every profile unless one is named.
28    List {
29        /// Restrict the listing to one ACME endpoint.
30        #[arg(long)]
31        profile: Option<String>,
32        /// Restrict the listing to the accounts one EAB credential bound.
33        #[arg(long)]
34        eab_kid: Option<String>,
35        /// Rows per page. A value below 1 is read as 1.
36        #[arg(long, default_value_t = DEFAULT_LIMIT)]
37        limit: i64,
38        /// Rows to skip before the page starts.
39        #[arg(long, default_value_t = 0)]
40        offset: i64,
41        /// Print the page as JSON: `{items, total, limit, offset}`.
42        #[arg(long)]
43        json: bool,
44    },
45    /// Show one account.
46    Show {
47        /// The account id.
48        id: String,
49        /// Print it as JSON.
50        #[arg(long)]
51        json: bool,
52    },
53    /// Replace an account's contact list.
54    UpdateContact {
55        /// The account id.
56        id: String,
57        /// A contact URL, `mailto:` for an address. Repeat it for several;
58        /// omit it to clear the list.
59        #[arg(long = "contact")]
60        contact: Vec<String>,
61    },
62    /// Deactivate an account (RFC 8555 §7.3.6). There is no way back.
63    Deactivate {
64        /// The account id.
65        id: String,
66    },
67    /// Hard-delete the account and everything under it.
68    Delete {
69        /// The account id.
70        id: String,
71    },
72}
73
74pub async fn run_account_command(
75    command: AccountCommand,
76    yes: bool,
77    palette: Palette,
78    reader: &mut impl BufRead,
79    config: &Config,
80    database: Arc<Database>,
81) -> Result<(), CliError> {
82    match command {
83        AccountCommand::List {
84            profile,
85            eab_kid,
86            limit,
87            offset,
88            json,
89        } => {
90            let window = Window::resolve(limit, offset);
91            let (accounts, total) = Account::search(
92                profile.as_deref(),
93                eab_kid.as_deref(),
94                window.limit,
95                window.offset,
96                &database,
97            )
98            .await?;
99            render::print_page(
100                &accounts,
101                total,
102                window,
103                json,
104                |a| admin::render_account_json(a, &config.server.base_url),
105                |a| render::render_account_line(a, palette),
106            );
107        }
108        AccountCommand::Show { id, json } => match Account::find_any_by_id(&id, &database).await? {
109            None => return Err(not_found(&id)),
110            Some(account) if json => {
111                println!(
112                    "{}",
113                    admin::render_account_json(&account, &config.server.base_url)
114                );
115            }
116            Some(account) => print!("{}", render::render_account_detail_text(&account, palette)),
117        },
118        AccountCommand::UpdateContact { id, contact } => {
119            match admin::update_account_contact(&id, contact, database.clone())
120                .await
121                .map_err(|error| match error {
122                    admin::ContactError::Invalid(detail) => CliError::bad_request(detail),
123                    admin::ContactError::Database(error) => CliError::from(error),
124                })? {
125                None => return Err(not_found(&id)),
126                Some(account) => {
127                    audit_admin::record_cli_action(&database, |actor, client| {
128                        audit_admin::account_contact_updated(
129                            actor,
130                            client,
131                            &account,
132                            &account.contact,
133                        )
134                    })
135                    .await;
136                    println!("{}", render::render_account_line(&account, palette));
137                }
138            }
139        }
140        AccountCommand::Deactivate { id } => {
141            // Queued, not sent: the running server's worker delivers it. A
142            // configuration that mounts no profile has nothing to notify
143            // through, which is not a reason to refuse the deactivation.
144            let notifiers = super::offline_notifiers(config, database.clone()).unwrap_or_default();
145            match admin::deactivate_account(
146                &id,
147                database.clone(),
148                |profile| notifiers.get(profile).cloned(),
149                None,
150            )
151            .await?
152            {
153                None => return Err(not_found(&id)),
154                Some(account) => {
155                    audit_admin::record_cli_action(&database, |actor, client| {
156                        audit_admin::account_deactivated(actor, client, &account)
157                    })
158                    .await;
159                    println!("{}", render::render_account_line(&account, palette));
160                }
161            }
162        }
163        AccountCommand::Delete { id } => {
164            // Read the account before the delete: the audit row names its
165            // profile, and the row is gone by the time the confirmation
166            // returns. The *count* comes back with the outcome for the same
167            // reason and a sharper one — counting afterwards counts the orders
168            // the `ON DELETE CASCADE` has already removed, which is zero every
169            // time.
170            let doomed = Account::find_any_by_id(&id, &database).await?;
171            match admin::confirm_delete_account(&id, yes, reader, database.clone()).await? {
172                DeleteOutcome::NotFound => return Err(not_found(&id)),
173                DeleteOutcome::LiveCertificates(live) => {
174                    return Err(CliError::bad_request(admin::live_certificates_refusal(
175                        &format!("account {id}"),
176                        live,
177                    )));
178                }
179                DeleteOutcome::Cancelled => println!("Cancelled."),
180                DeleteOutcome::Deleted(deleted) => {
181                    if let Some(account) = doomed {
182                        audit_admin::record_cli_action(&database, |actor, client| {
183                            audit_admin::account_deleted(actor, client, &account, deleted.cascaded)
184                        })
185                        .await;
186                    }
187                    println!(
188                        "Deleted account {id} ({} order(s) cascaded).",
189                        deleted.cascaded
190                    );
191                }
192            }
193        }
194    }
195    Ok(())
196}
197
198fn not_found(id: &str) -> CliError {
199    CliError::bad_request(acme_proxy_admin::admin::subject::Subject::Account.missing(id))
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use acme_proxy_core::audit::ClientContext;
206
207    /// Every arm taking an id reports the same thing for one that does not
208    /// exist — and reports it as a value, so the caller decides the exit code.
209    #[tokio::test]
210    async fn every_arm_refuses_an_unknown_account() {
211        let database = Arc::new(Database::connect_in_memory().await.unwrap());
212        let config = Config::default();
213        let expected = CliError::bad_request("no such account: acct-nope".to_string());
214
215        let commands = vec![
216            AccountCommand::Show {
217                id: "acct-nope".to_string(),
218                json: false,
219            },
220            AccountCommand::UpdateContact {
221                id: "acct-nope".to_string(),
222                contact: vec!["mailto:someone@example.com".to_string()],
223            },
224            AccountCommand::Deactivate {
225                id: "acct-nope".to_string(),
226            },
227            AccountCommand::Delete {
228                id: "acct-nope".to_string(),
229            },
230        ];
231        for command in commands {
232            let mut reader: &[u8] = &[];
233            let error = run_account_command(
234                command,
235                true,
236                Palette::plain(),
237                &mut reader,
238                &config,
239                database.clone(),
240            )
241            .await
242            .expect_err("an unknown account must fail");
243            assert_eq!(error, expected);
244        }
245    }
246
247    /// A contact `newAccount` would refuse is refused here too, as the
248    /// operator's error, and the account keeps the contact it had.
249    #[tokio::test]
250    async fn update_contact_refuses_what_new_account_refuses() {
251        let database = Arc::new(Database::connect_in_memory().await.unwrap());
252        let config = Config::default();
253        let (account, _) = Account::find_or_create(
254            "default",
255            &[2, 7, 1],
256            vec!["mailto:ops@example.com".to_string()],
257            &ClientContext::default(),
258            &database,
259        )
260        .await
261        .unwrap();
262
263        let mut reader: &[u8] = &[];
264        let error = run_account_command(
265            AccountCommand::UpdateContact {
266                id: account.id.to_string(),
267                contact: vec!["tel:+15555550100".to_string()],
268            },
269            true,
270            Palette::plain(),
271            &mut reader,
272            &config,
273            database.clone(),
274        )
275        .await
276        .expect_err("an unsupported scheme must be refused");
277        assert_eq!(error.kind(), crate::cli::CliErrorKind::BadRequest);
278
279        let reloaded = Account::find_any_by_id(&account.id.to_string(), &database)
280            .await
281            .unwrap()
282            .unwrap();
283        assert_eq!(reloaded.contact, vec!["mailto:ops@example.com".to_string()]);
284    }
285
286    /// A successful mutation leaves one `cli`-attributed audit row carrying the
287    /// account's own id and profile; a declined delete leaves none.
288    #[tokio::test]
289    async fn a_mutation_writes_an_audit_row_and_a_decline_does_not() {
290        use acme_proxy_store::audit::AuditEntry;
291        use acme_proxy_store::audit::AuditQuery;
292
293        let database = Arc::new(Database::connect_in_memory().await.unwrap());
294        let config = Config::default();
295        let (account, _) = Account::find_or_create(
296            "default",
297            &[3, 1, 4],
298            vec![],
299            &ClientContext::default(),
300            &database,
301        )
302        .await
303        .unwrap();
304
305        let mut reader: &[u8] = &[];
306        run_account_command(
307            AccountCommand::Deactivate {
308                id: account.id.to_string(),
309            },
310            true,
311            Palette::plain(),
312            &mut reader,
313            &config,
314            database.clone(),
315        )
316        .await
317        .unwrap();
318
319        let mut declined: &[u8] = b"n\n";
320        run_account_command(
321            AccountCommand::Delete {
322                id: account.id.to_string(),
323            },
324            false,
325            Palette::plain(),
326            &mut declined,
327            &config,
328            database.clone(),
329        )
330        .await
331        .unwrap();
332
333        let (rows, _) = AuditEntry::search(
334            &AuditQuery {
335                limit: 50,
336                ..AuditQuery::default()
337            },
338            &database,
339        )
340        .await
341        .unwrap();
342        assert_eq!(rows.len(), 1, "only the deactivation is a mutation");
343        assert_eq!(rows[0].event, "account_deactivated");
344        assert_eq!(rows[0].actor_kind, "cli");
345        assert_eq!(rows[0].profile, "default");
346        assert_eq!(
347            rows[0].account_id.as_deref(),
348            Some(account.id.to_string().as_str())
349        );
350    }
351
352    /// `account delete`'s audit row names how many orders went with the
353    /// account.
354    ///
355    /// It counted them *after* the delete, so the `ON DELETE CASCADE` had
356    /// already removed them and every row read `0 order(s) cascaded` however
357    /// many there were. The count now travels out of the confirmation, which is
358    /// where it was already computed to word the prompt.
359    #[tokio::test]
360    async fn deleting_an_account_records_what_actually_cascaded() {
361        use acme_proxy_core::identifier::Identifier;
362        use acme_proxy_store::audit::AuditEntry;
363        use acme_proxy_store::audit::AuditQuery;
364        use acme_proxy_store::order::Order;
365
366        let database = Arc::new(Database::connect_in_memory().await.unwrap());
367        let config = Config::default();
368        let (account, _) = Account::find_or_create(
369            "default",
370            &[2, 7, 1],
371            vec![],
372            &ClientContext::default(),
373            &database,
374        )
375        .await
376        .unwrap();
377        for name in ["a.example.com", "b.example.com"] {
378            Order::create(
379                "default",
380                account.id,
381                vec![Identifier::dns(name)],
382                acme_proxy_store::nonce::now_secs() + 3600,
383                None,
384                None,
385                &database,
386            )
387            .await
388            .unwrap();
389        }
390
391        let mut reader: &[u8] = &[];
392        run_account_command(
393            AccountCommand::Delete {
394                id: account.id.to_string(),
395            },
396            true,
397            Palette::plain(),
398            &mut reader,
399            &config,
400            database.clone(),
401        )
402        .await
403        .unwrap();
404
405        let (rows, _) = AuditEntry::search(
406            &AuditQuery {
407                limit: 5,
408                ..AuditQuery::default()
409            },
410            &database,
411        )
412        .await
413        .unwrap();
414        assert_eq!(rows.len(), 1);
415        assert_eq!(rows[0].event, "account_deleted");
416        assert_eq!(
417            rows[0].detail.as_deref(),
418            Some("2 order(s) cascaded"),
419            "the count must be what the cascade actually took"
420        );
421    }
422
423    /// `delete` without `--yes` asks first, and a refusal is a success: the
424    /// operator answered, nothing was destroyed.
425    #[tokio::test]
426    async fn a_declined_delete_is_not_a_failure() {
427        let database = Arc::new(Database::connect_in_memory().await.unwrap());
428        let config = Config::default();
429        let (account, _) = Account::find_or_create(
430            "default",
431            &[7, 7, 7],
432            vec![],
433            &ClientContext::default(),
434            &database,
435        )
436        .await
437        .unwrap();
438
439        let mut reader: &[u8] = b"n\n";
440        run_account_command(
441            AccountCommand::Delete {
442                id: account.id.to_string(),
443            },
444            false,
445            Palette::plain(),
446            &mut reader,
447            &config,
448            database.clone(),
449        )
450        .await
451        .unwrap();
452
453        assert!(
454            Account::find_any_by_id(account.id.to_string().as_str(), &database)
455                .await
456                .unwrap()
457                .is_some(),
458            "a declined delete must leave the account in place"
459        );
460    }
461
462    /// The listing takes a window, and a nonsense one is corrected rather than
463    /// handed to SQL — where `LIMIT -1` means *no limit* in SQLite, the one
464    /// answer a page must never accidentally give. `audit list`'s rule, now
465    /// this one's.
466    #[tokio::test]
467    async fn list_runs_in_both_shapes_and_clamps_a_nonsense_window() {
468        let database = Arc::new(Database::connect_in_memory().await.unwrap());
469        let config = Config::default();
470        for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
471            Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
472                .await
473                .unwrap();
474        }
475
476        let mut reader: &[u8] = &[];
477        for (limit, offset, json) in [(2, 0, false), (2, 2, false), (2, 0, true), (0, -5, false)] {
478            run_account_command(
479                AccountCommand::List {
480                    profile: None,
481                    eab_kid: None,
482                    limit,
483                    offset,
484                    json,
485                },
486                true,
487                Palette::plain(),
488                &mut reader,
489                &config,
490                database.clone(),
491            )
492            .await
493            .unwrap_or_else(|error| panic!("--limit {limit} --offset {offset}: {error}"));
494        }
495    }
496
497    /// The window reaches the query rather than being clamped and dropped: two
498    /// pages of two over three rows do not overlap, and the total stays the
499    /// unpaged count on both. Asserted against the model the command calls,
500    /// since a command body prints rather than returns.
501    #[tokio::test]
502    async fn consecutive_pages_do_not_overlap_and_the_total_stays_unpaged() {
503        let database = Arc::new(Database::connect_in_memory().await.unwrap());
504        for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
505            Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
506                .await
507                .unwrap();
508        }
509
510        let (first, total) = Account::search(None, None, 2, 0, &database).await.unwrap();
511        let (second, also_total) = Account::search(None, None, 2, 2, &database).await.unwrap();
512
513        assert_eq!(total, 3);
514        assert_eq!(also_total, 3, "the total is the table, not the page");
515        assert_eq!(first.len(), 2);
516        assert_eq!(second.len(), 1);
517        for account in &second {
518            assert!(
519                !first.iter().any(|earlier| earlier.id == account.id),
520                "a row appeared on two pages"
521            );
522        }
523    }
524
525    /// The JSON arms render through `admin::render_account_json`, which needs
526    /// the configured `base_url` — a separate branch from the line renderer.
527    #[tokio::test]
528    async fn the_json_arms_render() {
529        let database = Arc::new(Database::connect_in_memory().await.unwrap());
530        let config = Config::default();
531        let (account, _) = Account::find_or_create(
532            "default",
533            &[9, 9, 9],
534            vec![],
535            &ClientContext::default(),
536            &database,
537        )
538        .await
539        .unwrap();
540
541        let mut reader: &[u8] = &[];
542        run_account_command(
543            AccountCommand::List {
544                profile: Some("default".to_string()),
545                eab_kid: None,
546                limit: DEFAULT_LIMIT,
547                offset: 0,
548                json: true,
549            },
550            true,
551            Palette::plain(),
552            &mut reader,
553            &config,
554            database.clone(),
555        )
556        .await
557        .unwrap();
558
559        run_account_command(
560            AccountCommand::Show {
561                id: account.id.to_string(),
562                json: true,
563            },
564            true,
565            Palette::plain(),
566            &mut reader,
567            &config,
568            database,
569        )
570        .await
571        .unwrap();
572    }
573
574    /// `account delete` over a live certificate fails with the shared wording
575    /// and writes no audit row, even with `--yes`.
576    #[tokio::test]
577    async fn delete_refuses_an_account_holding_a_live_certificate() {
578        let database = Arc::new(Database::connect_in_memory().await.unwrap());
579        let account = acme_proxy_store::testutil::account_id(&database).await;
580        acme_proxy_store::testutil::certified_order(&database, account, None).await;
581
582        let error = run_account_command(
583            AccountCommand::Delete {
584                id: account.to_string(),
585            },
586            true,
587            Palette::plain(),
588            &mut &b""[..],
589            &Config::default(),
590            database.clone(),
591        )
592        .await
593        .expect_err("a live certificate must refuse the delete");
594        assert_eq!(
595            error,
596            CliError::bad_request(admin::live_certificates_refusal(
597                &format!("account {account}"),
598                1
599            ))
600        );
601        let (rows, _) = acme_proxy_store::audit::AuditEntry::search(
602            &acme_proxy_store::audit::AuditQuery::default(),
603            &database,
604        )
605        .await
606        .unwrap();
607        assert!(rows.is_empty());
608    }
609}