Skip to main content

acme_proxy/cli/
eab.rs

1//! `acme-proxy eab` — mint, list, revoke and delete external-account
2//! credentials (RFC 8555 §7.3.4).
3//!
4//! The secret is printed **once**, by `create`: nothing reads it back, and a
5//! lost credential is replaced rather than recovered. A credential scoped to a
6//! profile is checked against the mounted set here, as it is on `/api` and
7//! `/ui`, because one scoped to an endpoint this configuration does not serve
8//! would be accepted and then never usable.
9//!
10//! `delete` is the one command with a blast radius beyond its own row: it may
11//! deactivate or delete the accounts the credential bound, and is refused
12//! outright when that would leave a live certificate impossible to revoke.
13
14use std::io::BufRead;
15use std::sync::Arc;
16
17use clap::Subcommand;
18
19use crate::cli::CliError;
20use crate::cli::render;
21use crate::cli::window::{DEFAULT_LIMIT, Window};
22use acme_proxy_admin::admin;
23use acme_proxy_admin::admin::EabDeleteOutcome;
24use acme_proxy_core::config::Config;
25use acme_proxy_core::palette::Palette;
26use acme_proxy_jobs::auditor::admin as audit_admin;
27use acme_proxy_store::db::Database;
28use acme_proxy_store::eab::BoundAccounts;
29use acme_proxy_store::eab::DeletedEab;
30use acme_proxy_store::eab::Eab;
31
32#[derive(Subcommand)]
33pub enum EabCommand {
34    /// Generate a new EAB key and print its kid and secret. The secret is
35    /// shown this once and never again.
36    Create {
37        /// A name for the key, shown in listings and matched by `eab` filter
38        /// checks.
39        #[arg(long)]
40        label: Option<String>,
41        /// Bind the credential to one ACME endpoint. Omitted, it is accepted
42        /// at every profile — which is what an unscoped credential means.
43        #[arg(long)]
44        profile: Option<String>,
45        /// Print it as JSON.
46        #[arg(long)]
47        json: bool,
48    },
49    /// List EAB keys, newest first. Never shows the secret.
50    List {
51        /// Rows per page. A value below 1 is read as 1.
52        #[arg(long, default_value_t = DEFAULT_LIMIT)]
53        limit: i64,
54        /// Rows to skip before the page starts.
55        #[arg(long, default_value_t = 0)]
56        offset: i64,
57        /// Print the page as JSON: `{items, total, limit, offset}`.
58        #[arg(long)]
59        json: bool,
60    },
61    /// Show one EAB key. Never shows the secret.
62    Show {
63        /// The key id.
64        kid: String,
65        /// Print it as JSON.
66        #[arg(long)]
67        json: bool,
68    },
69    /// Revoke a key. The row stays, so accounts registered with it still
70    /// resolve to it (and to its label, for `eab` filter checks).
71    Revoke {
72        /// The key id.
73        kid: String,
74    },
75    /// Delete a key. Accounts registered with it are kept unless told
76    /// otherwise, and then no longer resolve to any credential, so every `eab`
77    /// filter check refuses them.
78    Delete {
79        /// The key id.
80        kid: String,
81        /// Also deactivate every account registered with it. Their orders are
82        /// kept, so their certificates stay revocable.
83        #[arg(long, conflicts_with = "delete_accounts")]
84        deactivate_accounts: bool,
85        /// Also hard-delete every account registered with it, and their orders.
86        /// Refused while any of those orders holds a live certificate.
87        #[arg(long)]
88        delete_accounts: bool,
89    },
90}
91
92pub async fn run_eab_command(
93    command: EabCommand,
94    yes: bool,
95    palette: Palette,
96    reader: &mut impl BufRead,
97    config: &Config,
98    database: Arc<Database>,
99) -> Result<(), CliError> {
100    match command {
101        EabCommand::Create {
102            label,
103            profile,
104            json,
105        } => {
106            // The same refusal the panel and the API make: a credential scoped
107            // to an endpoint this configuration does not mount is accepted and
108            // then never usable, and the operator is still looking at what they
109            // typed. Only where `--profile` was given — a credential valid
110            // everywhere needs no profile to exist, and resolving would refuse
111            // a configuration that mounts none.
112            if profile.is_some() {
113                let mounted = config
114                    .resolve_profiles()
115                    .map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
116                if let Some(message) = admin::unmounted_profile_refusal(
117                    |name| mounted.iter().any(|profile| profile.name == name),
118                    profile.as_deref(),
119                    "omit --profile",
120                ) {
121                    return Err(CliError::failed(message));
122                }
123            }
124
125            let eab = Eab::create(label, profile, &database).await?;
126            audit_admin::record_cli_action(&database, |actor, client| {
127                audit_admin::eab_created(
128                    actor,
129                    client,
130                    &eab.kid.to_string(),
131                    eab.profile.as_deref(),
132                    eab.label.as_deref(),
133                )
134            })
135            .await;
136            if json {
137                println!("{}", admin::render_eab_created_json(&eab));
138            } else {
139                print!("{}", render::render_eab_created_text(&eab, palette));
140            }
141        }
142        EabCommand::List {
143            limit,
144            offset,
145            json,
146        } => {
147            let window = Window::resolve(limit, offset);
148            let (keys, total) = Eab::search(window.limit, window.offset, &database).await?;
149            render::print_page(&keys, total, window, json, admin::render_eab_json, |eab| {
150                render::render_eab_line(eab, palette)
151            });
152        }
153        EabCommand::Show { kid, json } => match Eab::find_any_by_kid(&kid, &database).await? {
154            None => return Err(not_found(&kid)),
155            Some(eab) if json => println!("{}", admin::render_eab_json(&eab)),
156            Some(eab) => println!("{}", render::render_eab_line(&eab, palette)),
157        },
158        EabCommand::Revoke { kid } => {
159            let subject = Eab::find_any_by_kid(&kid, &database).await?;
160            if !Eab::revoke(&kid, &database).await? {
161                return Err(not_found(&kid));
162            }
163            // A repeat revoke changes nothing, so it records nothing — the
164            // `RevokeOutcome::AlreadyRevoked` rule on the certificate side.
165            if subject.as_ref().is_some_and(|eab| eab.status == "active") {
166                let profile = subject.as_ref().and_then(|eab| eab.profile.as_deref());
167                audit_admin::record_cli_action(&database, |actor, client| {
168                    audit_admin::eab_revoked(actor, client, &kid, profile)
169                })
170                .await;
171            }
172            println!("Revoked EAB key {kid}.");
173        }
174        EabCommand::Delete {
175            kid,
176            deactivate_accounts,
177            delete_accounts,
178        } => {
179            let accounts = if delete_accounts {
180                BoundAccounts::Delete
181            } else if deactivate_accounts {
182                BoundAccounts::Deactivate
183            } else {
184                BoundAccounts::Keep
185            };
186            match admin::confirm_delete_eab(&kid, accounts, yes, reader, database.clone()).await? {
187                EabDeleteOutcome::NotFound => return Err(not_found(&kid)),
188                EabDeleteOutcome::LiveCertificates {
189                    accounts,
190                    certificates,
191                } => {
192                    return Err(CliError::bad_request(admin::eab_live_certificates_refusal(
193                        &kid,
194                        accounts,
195                        certificates,
196                    )));
197                }
198                EabDeleteOutcome::Cancelled => println!("Cancelled."),
199                EabDeleteOutcome::Deleted(deleted) => {
200                    audit_admin::record_cli_actions(&database, |actor, client| {
201                        audit_admin::eab_deleted_records(actor, client, &deleted)
202                    })
203                    .await;
204                    println!("{}", deleted_line(&kid, &deleted));
205                }
206            }
207        }
208    }
209    Ok(())
210}
211
212/// What `eab delete` prints once it has happened.
213fn deleted_line(kid: &str, deleted: &DeletedEab) -> String {
214    match deleted.accounts {
215        BoundAccounts::Keep => format!(
216            "Deleted EAB key {kid}; {} account(s) registered with it were kept.",
217            deleted.remaining
218        ),
219        BoundAccounts::Deactivate => format!(
220            "Deleted EAB key {kid}; {} account(s) deactivated, their orders kept.",
221            deleted.deactivated.len()
222        ),
223        BoundAccounts::Delete => format!(
224            "Deleted EAB key {kid} ({} account(s), {} order(s) deleted).",
225            deleted.deleted.len(),
226            deleted
227                .deleted
228                .iter()
229                .map(|(_, orders)| orders)
230                .sum::<u64>()
231        ),
232    }
233}
234
235fn not_found(kid: &str) -> CliError {
236    CliError::bad_request(acme_proxy_admin::admin::subject::Subject::EabCredential.missing(kid))
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    /// A configuration with no profiles, which is all a command that does not
244    /// name one ever reads.
245    fn config() -> Config {
246        Config::default()
247    }
248
249    /// Loads a `Config` the way the server does, so `resolve_profiles` has the
250    /// raw sources it needs — `cli::profile`'s helper, and for its reason.
251    fn config_from(body: &str) -> Config {
252        let _lock = acme_proxy_core::config::ENV_LOCK
253            .lock()
254            .unwrap_or_else(std::sync::PoisonError::into_inner);
255        let dir = acme_proxy_core::testutil::TempDir::new("eab");
256        std::fs::write(dir.join("config.toml"), body).unwrap();
257        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
258        unsafe {
259            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
260        }
261        let config = Config::load().expect("the configuration must load");
262        unsafe {
263            std::env::remove_var("ACME_PROXY_CONFIG");
264        }
265        config
266    }
267
268    /// A credential scoped to an endpoint this configuration does not mount
269    /// would be accepted and then never usable, so it is refused where the
270    /// operator can still fix the spelling — the same rule `/api` and `/ui`
271    /// apply.
272    #[tokio::test]
273    async fn create_refuses_a_profile_that_is_not_mounted() {
274        let database = Arc::new(Database::connect_in_memory().await.unwrap());
275        let config = config_from(
276            r#"
277            [profiles.default]
278            signer.backend = "local_ca"
279            "#,
280        );
281
282        let error = run_eab_command(
283            EabCommand::Create {
284                label: None,
285                profile: Some("typo".to_string()),
286                json: false,
287            },
288            true,
289            Palette::plain(),
290            &mut &b""[..],
291            &config,
292            database.clone(),
293        )
294        .await
295        .expect_err("a credential for an unmounted endpoint must be refused");
296        assert!(format!("{error:?}").contains("typo"), "{error:?}");
297
298        // And nothing was written.
299        let (rows, _) = Eab::search(50, 0, &database).await.unwrap();
300        assert!(rows.is_empty());
301
302        // The profile that is mounted is minted as usual.
303        run_eab_command(
304            EabCommand::Create {
305                label: None,
306                profile: Some("default".to_string()),
307                json: false,
308            },
309            true,
310            Palette::plain(),
311            &mut &b""[..],
312            &config,
313            database.clone(),
314        )
315        .await
316        .expect("a mounted endpoint is fine");
317    }
318
319    #[tokio::test]
320    async fn show_revoke_and_delete_refuse_an_unknown_kid() {
321        let database = Arc::new(Database::connect_in_memory().await.unwrap());
322        let expected = CliError::bad_request("no such EAB credential: kid-nope".to_string());
323
324        for command in [
325            EabCommand::Show {
326                kid: "kid-nope".to_string(),
327                json: false,
328            },
329            EabCommand::Revoke {
330                kid: "kid-nope".to_string(),
331            },
332            EabCommand::Delete {
333                kid: "kid-nope".to_string(),
334                deactivate_accounts: false,
335                delete_accounts: true,
336            },
337        ] {
338            let error = run_eab_command(
339                command,
340                true,
341                Palette::plain(),
342                &mut &b""[..],
343                &config(),
344                database.clone(),
345            )
346            .await
347            .expect_err("an unknown kid must fail");
348            assert_eq!(error, expected);
349        }
350    }
351
352    /// `revoke` matches on the `kid` alone, so revoking twice is idempotent
353    /// and still reports success — only an unknown `kid` is an error.
354    #[tokio::test]
355    async fn a_created_key_shows_lists_and_revokes() {
356        let database = Arc::new(Database::connect_in_memory().await.unwrap());
357        let eab = Eab::create(Some("test".to_string()), None, &database)
358            .await
359            .unwrap();
360        // `--profile` is checked against the mounted set, so this one needs a
361        // configuration that mounts it.
362        let config = config_from(
363            r#"
364            [profiles.default]
365            signer.backend = "local_ca"
366            "#,
367        );
368
369        for command in [
370            EabCommand::Create {
371                label: None,
372                profile: Some("default".to_string()),
373                json: true,
374            },
375            EabCommand::List {
376                limit: DEFAULT_LIMIT,
377                offset: 0,
378                json: true,
379            },
380            EabCommand::Show {
381                kid: eab.kid.to_string(),
382                json: true,
383            },
384            EabCommand::Show {
385                kid: eab.kid.to_string(),
386                json: false,
387            },
388            EabCommand::Revoke {
389                kid: eab.kid.to_string(),
390            },
391        ] {
392            run_eab_command(
393                command,
394                true,
395                Palette::plain(),
396                &mut &b""[..],
397                &config,
398                database.clone(),
399            )
400            .await
401            .unwrap();
402        }
403
404        run_eab_command(
405            EabCommand::Revoke {
406                kid: eab.kid.to_string(),
407            },
408            true,
409            Palette::plain(),
410            &mut &b""[..],
411            &config,
412            database.clone(),
413        )
414        .await
415        .expect("revoking an already-revoked key is a no-op, not a failure");
416
417        assert_eq!(
418            Eab::find_any_by_kid(eab.kid.to_string().as_str(), &database)
419                .await
420                .unwrap()
421                .unwrap()
422                .status,
423            "revoked"
424        );
425    }
426
427    /// `eab_kid`'s credential with one bound account holding a certificate that
428    /// expires at `not_after`.
429    async fn bound_account(
430        database: &Arc<Database>,
431        not_after: Option<i64>,
432    ) -> (Eab, acme_proxy_store::account::Account) {
433        let eab = Eab::create(Some("tenant".to_string()), None, database)
434            .await
435            .unwrap();
436        let (mut account, _) = acme_proxy_store::account::Account::find_or_create(
437            "default",
438            &[7u8],
439            vec![],
440            &acme_proxy_core::audit::ClientContext::default(),
441            database,
442        )
443        .await
444        .unwrap();
445        account.set_eab_kid(eab.kid, database).await.unwrap();
446        acme_proxy_store::testutil::certified_order(database, account.id, not_after).await;
447        (eab, account)
448    }
449
450    async fn delete(
451        kid: &Eab,
452        deactivate_accounts: bool,
453        delete_accounts: bool,
454        database: &Arc<Database>,
455    ) -> Result<(), CliError> {
456        run_eab_command(
457            EabCommand::Delete {
458                kid: kid.kid.to_string(),
459                deactivate_accounts,
460                delete_accounts,
461            },
462            true,
463            Palette::plain(),
464            &mut &b""[..],
465            &config(),
466            database.clone(),
467        )
468        .await
469    }
470
471    async fn audit_events(database: &Database) -> Vec<String> {
472        let (rows, _) = acme_proxy_store::audit::AuditEntry::search(
473            &acme_proxy_store::audit::AuditQuery {
474                limit: 50,
475                ..Default::default()
476            },
477            database,
478        )
479        .await
480        .unwrap();
481        rows.into_iter().map(|row| row.event).collect()
482    }
483
484    /// `--delete-accounts` over a live certificate is refused with the shared
485    /// wording, writes no audit row, and changes nothing; deactivating instead
486    /// goes through and writes one row per account plus the credential's.
487    #[tokio::test]
488    async fn delete_with_accounts_refuses_a_live_certificate_and_deactivating_does_not() {
489        let database = Arc::new(Database::connect_in_memory().await.unwrap());
490        let (eab, account) = bound_account(&database, None).await;
491
492        let error = delete(&eab, false, true, &database)
493            .await
494            .expect_err("a live certificate must refuse the delete");
495        assert_eq!(
496            error,
497            CliError::bad_request(admin::eab_live_certificates_refusal(
498                &eab.kid.to_string(),
499                1,
500                1
501            ))
502        );
503        assert!(audit_events(&database).await.is_empty());
504
505        delete(&eab, true, false, &database).await.unwrap();
506        let mut events = audit_events(&database).await;
507        events.sort();
508        assert_eq!(events, ["account_deactivated", "eab_deleted"]);
509        assert_eq!(
510            acme_proxy_store::account::Account::find_any_by_id(
511                account.id.to_string().as_str(),
512                &database
513            )
514            .await
515            .unwrap()
516            .unwrap()
517            .status,
518            "deactivated"
519        );
520    }
521
522    /// With nothing live, `--delete-accounts` deletes the account and audits
523    /// both the account and the credential; a plain delete audits only the
524    /// credential.
525    #[tokio::test]
526    async fn delete_audits_the_credential_and_every_account_it_took() {
527        let database = Arc::new(Database::connect_in_memory().await.unwrap());
528        let (eab, _) = bound_account(&database, Some(1)).await;
529        delete(&eab, false, true, &database).await.unwrap();
530        let mut events = audit_events(&database).await;
531        events.sort();
532        assert_eq!(events, ["account_deleted", "eab_deleted"]);
533
534        let kept = Eab::create(None, None, &database).await.unwrap();
535        delete(&kept, false, false, &database).await.unwrap();
536        assert_eq!(audit_events(&database).await[0], "eab_deleted");
537    }
538
539    #[test]
540    fn deleted_line_says_what_became_of_the_accounts() {
541        use acme_proxy_store::eab::DeletedEab;
542
543        let eab = || Eab {
544            kid: uuid::Uuid::nil(),
545            secret: Vec::new(),
546            label: None,
547            profile: None,
548            status: "active".to_string(),
549            created_at: 0,
550        };
551        let kid = uuid::Uuid::nil().to_string();
552        let kept = DeletedEab {
553            eab: eab(),
554            accounts: BoundAccounts::Keep,
555            deactivated: Vec::new(),
556            deleted: Vec::new(),
557            remaining: 3,
558        };
559        assert!(deleted_line(&kid, &kept).ends_with("3 account(s) registered with it were kept."));
560        let deactivated = DeletedEab {
561            accounts: BoundAccounts::Deactivate,
562            ..kept
563        };
564        assert!(deleted_line(&kid, &deactivated).contains("0 account(s) deactivated"));
565        let deleted = DeletedEab {
566            eab: eab(),
567            accounts: BoundAccounts::Delete,
568            deactivated: Vec::new(),
569            deleted: Vec::new(),
570            remaining: 0,
571        };
572        assert!(deleted_line(&kid, &deleted).ends_with("(0 account(s), 0 order(s) deleted)."));
573    }
574}