1use 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 Create {
37 #[arg(long)]
40 label: Option<String>,
41 #[arg(long)]
44 profile: Option<String>,
45 #[arg(long)]
47 json: bool,
48 },
49 List {
51 #[arg(long, default_value_t = DEFAULT_LIMIT)]
53 limit: i64,
54 #[arg(long, default_value_t = 0)]
56 offset: i64,
57 #[arg(long)]
59 json: bool,
60 },
61 Show {
63 kid: String,
65 #[arg(long)]
67 json: bool,
68 },
69 Revoke {
72 kid: String,
74 },
75 Delete {
79 kid: String,
81 #[arg(long, conflicts_with = "delete_accounts")]
84 deactivate_accounts: bool,
85 #[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 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 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
212fn 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 fn config() -> Config {
246 Config::default()
247 }
248
249 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 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 #[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 let (rows, _) = Eab::search(50, 0, &database).await.unwrap();
300 assert!(rows.is_empty());
301
302 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 #[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 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 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 #[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 #[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}