1use std::fmt;
8use std::io::{self, Write};
9use std::sync::Arc;
10
11use crate::access::{Scope, Scopes};
12use crate::audit::Actor;
13use crate::audit::AuditError;
14use crate::clock::Clock;
15use crate::clock::SystemClock;
16use crate::config::Config;
17use crate::datadir::{self, LockError, Sharing, Work};
18use crate::envelope::{Key, KeyError};
19use crate::fs::OsFs;
20use crate::ids::{KeyId, TenantId};
21use crate::retention::Retention;
22use crate::tenants::{self, Removal, TenantError, TenantExamination, Tenants};
23use time::OffsetDateTime;
24use time::format_description::well_known::Rfc3339;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Command {
29 TenantCreate {
30 name: String,
31 retention: Option<String>,
32 },
33 TenantList,
34 TenantSetRetention {
36 id: String,
37 retention: String,
38 },
39 TenantRemove {
40 id: String,
41 force: bool,
42 override_holds: bool,
43 },
44 KeyCreate {
45 owner: Owner,
46 label: String,
47 },
48 KeyList {
49 tenant: Option<String>,
50 },
51 KeyRevoke {
52 id: String,
53 },
54 AuditExport {
55 log: Log,
56 since: Option<i64>,
57 },
58 RekeyTenant {
60 id: String,
61 },
62 RekeyMaster {
64 new_master: std::path::PathBuf,
65 },
66 Check,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Owner {
73 Tenant { id: String, scopes: Vec<String> },
74 Operator,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Log {
80 Tenant(String),
81 Admin,
82}
83
84impl Command {
85 pub fn work(&self) -> Work {
87 match self {
88 Command::TenantCreate { .. } => Work::TenantCreate,
89 Command::TenantList => Work::TenantList,
90 Command::TenantSetRetention { .. } => Work::TenantSetRetention,
91 Command::TenantRemove { .. } => Work::TenantRemove,
92 Command::KeyCreate { .. } => Work::KeyCreate,
93 Command::KeyList { .. } => Work::KeyList,
94 Command::KeyRevoke { .. } => Work::KeyRevoke,
95 Command::AuditExport { .. } => Work::AuditExport,
96 Command::RekeyTenant { .. } => Work::RekeyTenant,
97 Command::RekeyMaster { .. } => Work::RekeyMaster,
98 Command::Check => Work::Check,
99 }
100 }
101}
102
103#[derive(Debug)]
105pub enum CommandError {
106 DataDir(LockError),
107 MasterKey(KeyError),
108 WrongMasterKey,
110 BadPeriod(String),
113 Tenant(TenantError),
114 UnknownKey,
116 Output(io::Error),
118 SameMasterKey,
120 CheckFound(usize),
122}
123
124impl fmt::Display for CommandError {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 match self {
127 CommandError::DataDir(e) => write!(f, "{e}"),
128 CommandError::MasterKey(e) => write!(f, "the master key cannot be read: {e:?}"),
129 CommandError::WrongMasterKey | CommandError::Tenant(TenantError::WrongKey) => {
130 f.write_str("the master key is not the one server.sqlite was made under")
131 }
132 CommandError::BadPeriod(text) => write!(
133 f,
134 "{text:?} is not a period such as 7y, 18m or 90d (or, for set-retention, default)"
135 ),
136 CommandError::UnknownKey => f.write_str("no key has this id"),
137 CommandError::Output(e) => write!(f, "the output could not be written: {e}"),
138 CommandError::SameMasterKey => {
139 f.write_str("the new master key is the one already in use")
140 }
141 CommandError::CheckFound(n) => write!(f, "check found {n} problem(s); see its report"),
142 CommandError::Tenant(e) => f.write_str(match e {
143 TenantError::NotFound => "no tenant has this id",
144 TenantError::NameTaken => "another tenant has this name",
145 TenantError::BadName => {
146 "a tenant name is 1 to 128 characters, with no control characters and no space at either end"
147 }
148 TenantError::HoldsVcons => "the tenant holds vCons; add --force to remove them too",
149 TenantError::UnderHold => {
150 "a vCon of the tenant is under a legal hold; only --override-holds removes it"
151 }
152 TenantError::BadScopes => {
153 "a tenant key's scopes are one or more of write, read, delete, legal and audit, each named once"
154 }
155 TenantError::LimitReached => {
156 "the master key has wrapped all the tenant keys it may; run rekey master"
157 }
158 TenantError::AuditFailed => "the audit record could not be written",
159 TenantError::Closing => "the tenant is being removed",
160 TenantError::CliOnly => "only the command line may do this",
161 TenantError::WrongKey => unreachable!("matched above"),
162 TenantError::Io => "the data could not be read or written",
163 }),
164 }
165 }
166}
167
168pub fn run(config: &Config, command: &Command, out: &mut dyn Write) -> Result<(), CommandError> {
171 let sharing = command.work().sharing();
172 let _lock = datadir::lock(&config.data_dir, sharing).map_err(CommandError::DataDir)?;
173 let master = Key::load_master(&config.master_key).map_err(CommandError::MasterKey)?;
174 if let Command::RekeyMaster { new_master } = command {
177 return rekey_master(config, &master, new_master, out);
178 }
179 let open = if sharing == Sharing::Beside || matches!(command, Command::Check) {
183 Tenants::open_beside
184 } else {
185 Tenants::open
186 };
187 let tenants = open(
188 &config.data_dir,
189 master,
190 Arc::new(OsFs),
191 Arc::new(SystemClock),
192 )
193 .map_err(|e| match e {
194 TenantError::WrongKey => CommandError::WrongMasterKey,
195 other => CommandError::Tenant(other),
196 })?;
197 let who = Actor::Cli;
198 match command {
199 Command::TenantCreate { name, retention } => {
200 let retention = retention.as_deref().map(period).transpose()?;
201 let id = tenants.create(who, name, retention)?;
202 line(out, &serde_json::json!({ "id": id.to_string() }))
203 }
204 Command::TenantList => {
205 for tenant in tenants.list(who)? {
206 line(out, &tenant.to_json())?;
207 }
208 Ok(())
209 }
210 Command::TenantSetRetention { id, retention } => {
211 let retention = match retention.as_str() {
212 "default" => None,
213 text => Some(period(text)?),
214 };
215 let id = tenant_id(id)?;
216 tenants.set_retention(who, id, retention)?;
217 line(
218 out,
219 &serde_json::json!({
220 "id": id.to_string(),
221 "retention": retention.map(|r| r.to_string()),
222 }),
223 )
224 }
225 Command::TenantRemove {
226 id,
227 force,
228 override_holds,
229 } => {
230 let removal = match (force, override_holds) {
231 (_, true) => Removal::ForceOverridingHolds,
232 (true, false) => Removal::Force,
233 (false, false) => Removal::Empty,
234 };
235 Ok(tenants.remove(who, tenant_id(id)?, removal)?)
236 }
237 Command::KeyCreate { owner, label } => {
238 let (tenant, scopes) = match owner {
239 Owner::Tenant { id, scopes } => (
240 Some(tenant_id(id)?),
241 Scopes::named(scopes).ok_or(CommandError::Tenant(TenantError::BadScopes))?,
242 ),
243 Owner::Operator => (None, Scopes::of(&[Scope::Admin])),
244 };
245 let made = tenants.create_key(who, tenant, scopes, label)?;
246 line(
247 out,
248 &serde_json::json!({ "id": made.id.to_string(), "token": made.token }),
249 )
250 }
251 Command::KeyList { tenant } => {
252 let tenant = tenant.as_deref().map(tenant_id).transpose()?;
253 for key in tenants.list_keys(who, tenant)? {
254 let shown = key.to_json().ok_or(CommandError::Tenant(TenantError::Io))?;
255 line(out, &shown)?;
256 }
257 Ok(())
258 }
259 Command::KeyRevoke { id } => {
260 let id = KeyId::parse(id).ok_or(CommandError::UnknownKey)?;
261 tenants.revoke_key(who, id).map_err(|e| match e {
262 TenantError::NotFound => CommandError::UnknownKey,
263 other => CommandError::Tenant(other),
264 })
265 }
266 Command::RekeyTenant { id } => Ok(tenants.rekey_tenant(who, tenant_id(id)?)?),
267 Command::RekeyMaster { .. } => unreachable!("rotated before the registry is opened"),
268 Command::Check => check(config, &tenants, out),
269 Command::AuditExport { log, since } => {
270 let exported = match log {
271 Log::Admin => tenants.export_admin(*since, out),
272 Log::Tenant(id) => tenants.store(tenant_id(id)?)?.export(*since, out),
273 };
274 exported.map_err(|e| match e {
275 AuditError::Write(e) => CommandError::Output(e),
276 AuditError::Sqlite(_) => CommandError::Tenant(TenantError::Io),
277 })
278 }
279 }
280}
281
282impl From<TenantError> for CommandError {
283 fn from(e: TenantError) -> CommandError {
284 CommandError::Tenant(e)
285 }
286}
287
288fn rekey_master(
290 config: &Config,
291 master: &Key,
292 new_master: &std::path::Path,
293 out: &mut dyn Write,
294) -> Result<(), CommandError> {
295 let new = Key::load_master(new_master).map_err(CommandError::MasterKey)?;
296 if new.id() == master.id() {
297 return Err(CommandError::SameMasterKey);
298 }
299 tenants::rekey_master(&config.data_dir, master, &new, SystemClock.now()).map_err(
300 |e| match e {
301 TenantError::WrongKey => CommandError::WrongMasterKey,
302 other => CommandError::Tenant(other),
303 },
304 )?;
305 line(
306 out,
307 &serde_json::json!({
308 "master_key_id": new.id().to_string(),
309 "next": "point master_key in the configuration at the new key file, start the server, then destroy every copy of the old master key",
310 }),
311 )
312}
313
314fn check(config: &Config, tenants: &Tenants, out: &mut dyn Write) -> Result<(), CommandError> {
317 let now = SystemClock.now();
318 let found = tenants.examine(config.retention_default, now)?;
319 let time = |at: Option<OffsetDateTime>| at.and_then(|at| at.format(&Rfc3339).ok());
320 let mut total = 0;
321 for tenant in &found.tenants {
322 let named = problems(tenant, config.purge_interval, now);
323 total += named.len();
324 let store = tenant.store.as_ref().ok();
325 line(
326 out,
327 &serde_json::json!({
328 "tenant": tenant.id.to_string(),
329 "name": tenant.name,
330 "versions": store.map(|s| s.versions),
331 "damaged": store.map(|s| s.damaged.iter().map(ToString::to_string).collect::<Vec<_>>()),
332 "leftovers": store.map(|s| &s.leftovers),
333 "past_due": store.map(|s| s.due),
334 "oldest_past_due": time(store.and_then(|s| s.oldest_due_since)),
335 "purges_awaiting_rotation": store.map(|s| s.purges_since_key),
336 "oldest_purge_awaiting_rotation": time(store.and_then(|s| s.oldest_purge_since_key)),
337 "rotation_stopped": tenant.rotation_stopped,
338 "problems": named,
339 }),
340 )?;
341 }
342 line(
343 out,
344 &serde_json::json!({
345 "tenants": found.tenants.len(),
346 "removed_tenants_awaiting_master_rotation": found.removed_since_master,
347 "oldest_removal_awaiting_master_rotation": time(found.oldest_removed_since_master),
348 "problems": total,
349 }),
350 )?;
351 match total {
352 0 => Ok(()),
353 n => Err(CommandError::CheckFound(n)),
354 }
355}
356
357pub fn problems(
362 tenant: &TenantExamination,
363 purge_interval: std::time::Duration,
364 now: OffsetDateTime,
365) -> Vec<String> {
366 let mut found = Vec::new();
367 if tenant.rotation_stopped {
368 found.push("a key rotation was stopped part way; run rekey tenant again".to_owned());
369 }
370 match &tenant.store {
371 Err(_) => found.push("its catalog does not open under its tenant key".to_owned()),
372 Ok(store) => {
373 for version in &store.damaged {
374 found.push(format!("version {version} is missing or does not open"));
375 }
376 if let Some(since) = store.oldest_due_since
377 && now - since > purge_interval
378 {
379 found.push(format!(
380 "{} version(s) due for purge, the oldest longer than purge_interval: the sweep is not keeping up",
381 store.due
382 ));
383 }
384 }
385 }
386 found
387}
388
389fn period(text: &str) -> Result<Retention, CommandError> {
391 Retention::parse(text).ok_or_else(|| CommandError::BadPeriod(text.to_owned()))
392}
393
394fn tenant_id(text: &str) -> Result<TenantId, CommandError> {
397 TenantId::parse(text).ok_or(CommandError::Tenant(TenantError::NotFound))
398}
399
400fn line(out: &mut dyn Write, value: &serde_json::Value) -> Result<(), CommandError> {
402 writeln!(out, "{value}").map_err(CommandError::Output)
403}
404
405#[cfg(test)]
406mod tests {
407 use serde_json::{Value, json};
408
409 use super::*;
410 use crate::store::Caller;
411 use crate::store::testing::vcon;
412
413 const UUID: &str = "01a07da8-c2bb-83e5-b9a2-279e0d16bc46";
414
415 struct Setup {
416 _dir: tempfile::TempDir,
417 config: Config,
418 }
419
420 fn setup() -> Setup {
421 let dir = tempfile::tempdir().expect("temp dir");
422 let data = dir.path().join("data");
423 std::fs::create_dir(&data).expect("data dir");
424 let master = dir.path().join("master.key");
425 std::fs::write(&master, [0x5a; 32]).expect("master key file");
426 let config = crate::config::parse(&format!(
427 "listen = \"127.0.0.1:1\"\ndata_dir = {data:?}\nmaster_key = {master:?}\n\
428 [tls]\ncert = \"c\"\nkey = \"k\"\n"
429 ))
430 .expect("a configuration");
431 Setup { _dir: dir, config }
432 }
433
434 impl Setup {
435 fn run(&self, command: Command) -> Result<Vec<Value>, CommandError> {
437 let mut out = Vec::new();
438 run(&self.config, &command, &mut out)?;
439 Ok(String::from_utf8(out)
440 .expect("text")
441 .lines()
442 .map(|line| serde_json::from_str(line).expect("a JSON line"))
443 .collect())
444 }
445
446 fn create(&self, name: &str) -> String {
447 let out = self
448 .run(Command::TenantCreate {
449 name: name.to_owned(),
450 retention: None,
451 })
452 .expect("made");
453 out[0]["id"].as_str().expect("an id").to_owned()
454 }
455
456 fn tenants(&self) -> Tenants {
458 Tenants::open_beside(
459 &self.config.data_dir,
460 Key::load_master(&self.config.master_key).expect("master"),
461 Arc::new(OsFs),
462 Arc::new(SystemClock),
463 )
464 .expect("opens")
465 }
466
467 fn tenant_dirs(&self) -> Vec<String> {
468 let mut names: Vec<String> = std::fs::read_dir(self.config.data_dir.join("tenants"))
469 .expect("tenants dir")
470 .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
471 .collect();
472 names.sort();
473 names
474 }
475 }
476
477 fn tenant_error(result: Result<Vec<Value>, CommandError>) -> TenantError {
478 match result {
479 Err(CommandError::Tenant(e)) => e,
480 other => panic!("not a tenant error: {other:?}"),
481 }
482 }
483
484 #[test]
485 fn each_command_does_its_own_work() {
486 let id = || "x".to_owned();
487 for (command, work) in [
488 (
489 Command::TenantCreate {
490 name: id(),
491 retention: None,
492 },
493 Work::TenantCreate,
494 ),
495 (Command::TenantList, Work::TenantList),
496 (
497 Command::TenantSetRetention {
498 id: id(),
499 retention: id(),
500 },
501 Work::TenantSetRetention,
502 ),
503 (
504 Command::TenantRemove {
505 id: id(),
506 force: false,
507 override_holds: false,
508 },
509 Work::TenantRemove,
510 ),
511 (
512 Command::KeyCreate {
513 owner: Owner::Operator,
514 label: id(),
515 },
516 Work::KeyCreate,
517 ),
518 (Command::KeyList { tenant: None }, Work::KeyList),
519 (Command::KeyRevoke { id: id() }, Work::KeyRevoke),
520 (
521 Command::AuditExport {
522 log: Log::Admin,
523 since: None,
524 },
525 Work::AuditExport,
526 ),
527 (Command::RekeyTenant { id: id() }, Work::RekeyTenant),
528 (Command::Check, Work::Check),
529 (
530 Command::RekeyMaster {
531 new_master: id().into(),
532 },
533 Work::RekeyMaster,
534 ),
535 ] {
536 assert_eq!(command.work(), work, "{command:?}");
537 }
538 }
539
540 #[test]
541 fn tenant_create_prints_the_new_id_and_list_shows_it() {
542 let s = setup();
543 let out = s
544 .run(Command::TenantCreate {
545 name: "acme".to_owned(),
546 retention: Some("18m".to_owned()),
547 })
548 .expect("made");
549 assert_eq!(out.len(), 1);
550 let id = out[0]["id"].as_str().expect("an id").to_owned();
551 assert!(TenantId::parse(&id).is_some(), "{id}");
552 assert_eq!(out[0], json!({ "id": id }));
553 let beta = s.create("beta");
554 assert_eq!(
555 s.run(Command::TenantList).expect("listed"),
556 [
557 json!({ "id": id, "name": "acme", "retention": "18m" }),
558 json!({ "id": beta, "name": "beta", "retention": null }),
559 ]
560 );
561 }
562
563 #[test]
564 fn a_bad_period_is_refused_before_anything_is_made() {
565 let s = setup();
566 let error = s
567 .run(Command::TenantCreate {
568 name: "acme".to_owned(),
569 retention: Some("7w".to_owned()),
570 })
571 .expect_err("refused");
572 assert!(
573 matches!(&error, CommandError::BadPeriod(p) if p == "7w"),
574 "{error:?}"
575 );
576 assert!(error.to_string().contains("7w"), "{error}");
577 assert_eq!(
578 s.run(Command::TenantList).expect("listed"),
579 Vec::<Value>::new()
580 );
581 let id = s.create("acme");
582 let error = s
583 .run(Command::TenantSetRetention {
584 id,
585 retention: "forever".to_owned(),
586 })
587 .expect_err("refused");
588 assert!(matches!(&error, CommandError::BadPeriod(p) if p == "forever"));
589 }
590
591 #[test]
592 fn set_retention_takes_a_period_or_default() {
593 let s = setup();
594 let id = s.create("acme");
595 let set = |retention: &str| {
596 s.run(Command::TenantSetRetention {
597 id: id.clone(),
598 retention: retention.to_owned(),
599 })
600 .expect("set")
601 };
602 assert_eq!(set("90d"), [json!({ "id": id, "retention": "90d" })]);
603 assert_eq!(
604 s.run(Command::TenantList).expect("listed")[0]["retention"],
605 "90d"
606 );
607 assert_eq!(set("default"), [json!({ "id": id, "retention": null })]);
608 assert_eq!(
609 s.run(Command::TenantList).expect("listed")[0]["retention"],
610 Value::Null
611 );
612 }
613
614 #[test]
615 fn an_unknown_or_malformed_tenant_is_not_found() {
616 let s = setup();
617 for id in [TenantId::generate().to_string(), "not-an-id".to_owned()] {
618 assert_eq!(
619 tenant_error(s.run(Command::TenantSetRetention {
620 id: id.clone(),
621 retention: "7y".to_owned()
622 })),
623 TenantError::NotFound
624 );
625 assert_eq!(
626 tenant_error(s.run(Command::TenantRemove {
627 id: id.clone(),
628 force: true,
629 override_holds: false
630 })),
631 TenantError::NotFound
632 );
633 assert_eq!(
634 tenant_error(s.run(Command::KeyCreate {
635 owner: Owner::Tenant {
636 id: id.clone(),
637 scopes: vec!["read".to_owned()]
638 },
639 label: "l".to_owned()
640 })),
641 TenantError::NotFound
642 );
643 assert_eq!(
644 tenant_error(s.run(Command::KeyList {
645 tenant: Some(id.clone())
646 })),
647 TenantError::NotFound
648 );
649 assert_eq!(
650 tenant_error(s.run(Command::AuditExport {
651 log: Log::Tenant(id.clone()),
652 since: None
653 })),
654 TenantError::NotFound
655 );
656 }
657 }
658
659 #[test]
662 fn remove_goes_as_far_as_its_flags_allow() {
663 let s = setup();
664 let empty = s.create("empty");
665 let full = s.create("full");
666 {
667 let tenants = s.tenants();
668 let store = tenants
669 .store(TenantId::parse(&full).expect("id"))
670 .expect("store");
671 let caller = Caller { key: KeyId([1; 8]) };
672 let (found, body) = vcon(UUID, "kept");
673 store.create(&caller, &found, &body).expect("stored");
674 store.set_hold(&caller, found.uuid, true).expect("held");
675 }
676 let remove = |id: &str, force, override_holds| {
677 s.run(Command::TenantRemove {
678 id: id.to_owned(),
679 force,
680 override_holds,
681 })
682 };
683 assert_eq!(
684 remove(&empty, false, false).expect("removed"),
685 Vec::<Value>::new()
686 );
687 assert_eq!(
688 tenant_error(remove(&full, false, false)),
689 TenantError::HoldsVcons
690 );
691 assert_eq!(
692 tenant_error(remove(&full, true, false)),
693 TenantError::UnderHold
694 );
695 assert_eq!(
696 remove(&full, true, true).expect("removed"),
697 Vec::<Value>::new()
698 );
699 assert_eq!(
700 s.run(Command::TenantList).expect("listed"),
701 Vec::<Value>::new()
702 );
703 assert_eq!(s.tenant_dirs(), Vec::<String>::new());
704 }
705
706 #[test]
707 fn keys_are_made_listed_and_revoked() {
708 let s = setup();
709 let tenant = s.create("acme");
710 let made = s
711 .run(Command::KeyCreate {
712 owner: Owner::Tenant {
713 id: tenant.clone(),
714 scopes: vec!["write".to_owned(), "read".to_owned()],
715 },
716 label: "producer".to_owned(),
717 })
718 .expect("made");
719 let id = made[0]["id"].as_str().expect("id").to_owned();
720 let token = made[0]["token"].as_str().expect("token").to_owned();
721 assert_eq!(made.len(), 1);
722 assert_eq!(made[0].as_object().expect("object").len(), 2);
723 assert!(token.starts_with(&format!("vcs_{id}_")), "{token}");
724 let operator = s
725 .run(Command::KeyCreate {
726 owner: Owner::Operator,
727 label: "ops".to_owned(),
728 })
729 .expect("made")[0]["id"]
730 .as_str()
731 .expect("id")
732 .to_owned();
733
734 let listed = s.run(Command::KeyList { tenant: None }).expect("listed");
735 assert_eq!(listed.len(), 2);
736 assert_eq!(listed[0]["id"], id.as_str());
737 assert_eq!(listed[0]["tenant"], tenant.as_str());
738 assert_eq!(listed[0]["scopes"], json!(["write", "read"]));
739 assert_eq!(listed[0]["label"], "producer");
740 assert_eq!(listed[0]["revoked_at"], Value::Null);
741 assert!(
742 listed[0]["created_at"]
743 .as_str()
744 .is_some_and(|t| t.ends_with('Z'))
745 );
746 assert!(
747 listed.iter().all(|k| k.get("token").is_none()),
748 "never a token"
749 );
750 assert_eq!(listed[1]["id"], operator.as_str());
751 assert_eq!(listed[1]["tenant"], Value::Null);
752 assert_eq!(listed[1]["scopes"], json!(["admin"]));
753
754 let own = s
755 .run(Command::KeyList {
756 tenant: Some(tenant.clone()),
757 })
758 .expect("listed");
759 assert_eq!(own, listed[..1]);
760
761 assert_eq!(
762 s.run(Command::KeyRevoke { id: id.clone() })
763 .expect("revoked"),
764 Vec::<Value>::new()
765 );
766 let listed = s.run(Command::KeyList { tenant: None }).expect("listed");
767 assert!(listed[0]["revoked_at"].as_str().is_some(), "{listed:?}");
768 assert_eq!(listed[1]["revoked_at"], Value::Null);
769 }
770
771 #[test]
772 fn bad_scopes_and_unknown_keys_are_refused() {
773 let s = setup();
774 let tenant = s.create("acme");
775 for scopes in [
776 vec!["superuser"],
777 vec!["read", "superuser"],
778 vec!["read", "read"],
779 vec!["admin"],
780 vec![],
781 ] {
782 let error = s.run(Command::KeyCreate {
783 owner: Owner::Tenant {
784 id: tenant.clone(),
785 scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
786 },
787 label: "l".to_owned(),
788 });
789 assert_eq!(tenant_error(error), TenantError::BadScopes, "{scopes:?}");
790 }
791 assert_eq!(
792 s.run(Command::KeyList { tenant: None }).expect("listed"),
793 Vec::<Value>::new()
794 );
795 for id in ["0123456789abcdef".to_owned(), "nope".to_owned()] {
796 assert!(
797 matches!(
798 s.run(Command::KeyRevoke { id }),
799 Err(CommandError::UnknownKey)
800 ),
801 "unknown"
802 );
803 }
804 }
805
806 #[test]
807 fn audit_export_writes_either_log_from_a_sequence_number() {
808 let s = setup();
809 let tenant = s.create("acme");
810 s.run(Command::TenantList).expect("listed");
811 s.run(Command::KeyCreate {
812 owner: Owner::Tenant {
813 id: tenant.clone(),
814 scopes: vec!["read".to_owned()],
815 },
816 label: "l".to_owned(),
817 })
818 .expect("made");
819
820 let admin = s
821 .run(Command::AuditExport {
822 log: Log::Admin,
823 since: None,
824 })
825 .expect("exported");
826 let actions: Vec<&str> = admin
827 .iter()
828 .map(|r| r["action"].as_str().expect("action"))
829 .collect();
830 assert_eq!(actions, ["tenant_create", "tenant_list", "key_create"]);
831 assert!(admin.iter().all(|r| r["actor"] == "cli"));
832 let since = admin[0]["seq"].as_i64().expect("seq");
833 let later = s
834 .run(Command::AuditExport {
835 log: Log::Admin,
836 since: Some(since),
837 })
838 .expect("exported");
839 assert_eq!(later, admin[1..]);
840
841 let own = s
842 .run(Command::AuditExport {
843 log: Log::Tenant(tenant),
844 since: None,
845 })
846 .expect("exported");
847 let actions: Vec<&str> = own
848 .iter()
849 .map(|r| r["action"].as_str().expect("action"))
850 .collect();
851 assert_eq!(actions, ["key_create"]);
852 }
853
854 #[test]
855 fn rekey_tenant_rotates_the_key_and_prints_nothing() {
856 let s = setup();
857 let id = s.create("acme");
858 let tenant = TenantId::parse(&id).expect("id");
859 let before = s.tenants().tenant_key_id_for_test(tenant);
860 assert_eq!(
861 s.run(Command::RekeyTenant { id: id.clone() })
862 .expect("rekeyed"),
863 Vec::<Value>::new()
864 );
865 assert_ne!(s.tenants().tenant_key_id_for_test(tenant), before);
866 assert_eq!(
867 tenant_error(s.run(Command::RekeyTenant {
868 id: TenantId::generate().to_string()
869 })),
870 TenantError::NotFound
871 );
872 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
873 assert!(matches!(
874 s.run(Command::RekeyTenant { id }),
875 Err(CommandError::DataDir(LockError::InUse(Sharing::Alone)))
876 ));
877 drop(server);
878 }
879
880 #[test]
881 fn rekey_master_moves_the_data_to_the_new_master_key() {
882 let mut s = setup();
883 s.create("acme");
884 let new_file = s.config.master_key.with_file_name("new.key");
885 std::fs::write(&new_file, [0x5c; 32]).expect("new key");
886 assert!(matches!(
887 s.run(Command::RekeyMaster {
888 new_master: s.config.master_key.clone()
889 }),
890 Err(CommandError::SameMasterKey)
891 ));
892 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
893 assert!(matches!(
894 s.run(Command::RekeyMaster {
895 new_master: new_file.clone()
896 }),
897 Err(CommandError::DataDir(LockError::InUse(Sharing::Alone)))
898 ));
899 drop(server);
900 let out = s
901 .run(Command::RekeyMaster {
902 new_master: new_file.clone(),
903 })
904 .expect("rotated");
905 let new_id = Key::load_master(&new_file).expect("key").id().to_string();
906 assert_eq!(out.len(), 1);
907 assert_eq!(out[0]["master_key_id"], new_id.as_str());
908 assert!(
909 out[0]["next"]
910 .as_str()
911 .is_some_and(|n| n.contains("destroy"))
912 );
913 assert!(matches!(
914 s.run(Command::TenantList),
915 Err(CommandError::WrongMasterKey)
916 ));
917 s.config.master_key = new_file;
918 assert_eq!(s.run(Command::TenantList).expect("listed").len(), 1);
919 }
920
921 mod check {
922 use time::macros::datetime;
923
924 use super::*;
925 use crate::ids::VersionId;
926 use crate::store::Examination;
927
928 const NOW: OffsetDateTime = datetime!(2026-10-25 12:00 UTC);
929 const HOUR: std::time::Duration = std::time::Duration::from_secs(3600);
930
931 fn sound() -> Examination {
932 Examination {
933 versions: 3,
934 damaged: vec![],
935 leftovers: vec!["a.tmp".to_owned()],
936 due: 0,
937 oldest_due_since: None,
938 purges_since_key: 4,
939 oldest_purge_since_key: Some(datetime!(2026-10-01 00:00 UTC)),
940 }
941 }
942
943 fn tenant(store: Result<Examination, TenantError>, stopped: bool) -> TenantExamination {
944 TenantExamination {
945 id: TenantId([1; 16]),
946 name: "acme".to_owned(),
947 rotation_stopped: stopped,
948 store,
949 }
950 }
951
952 #[test]
953 fn leftovers_and_work_waiting_for_rotation_are_not_problems() {
954 assert_eq!(
955 problems(&tenant(Ok(sound()), false), HOUR, NOW),
956 Vec::<String>::new()
957 );
958 }
959
960 #[test]
961 fn each_problem_is_named() {
962 let damaged = Examination {
963 damaged: vec![VersionId([2; 16]), VersionId([3; 16])],
964 ..sound()
965 };
966 assert_eq!(problems(&tenant(Ok(damaged), false), HOUR, NOW).len(), 2);
967 let stopped = problems(&tenant(Ok(sound()), true), HOUR, NOW);
968 assert_eq!(stopped.len(), 1);
969 assert!(stopped[0].contains("rekey tenant"), "{stopped:?}");
970 let closed = problems(&tenant(Err(TenantError::Io), false), HOUR, NOW);
971 assert_eq!(closed.len(), 1);
972 assert!(closed[0].contains("catalog"), "{closed:?}");
973 }
974
975 #[test]
976 fn a_sweep_is_stalled_only_once_a_version_waited_longer_than_the_interval() {
977 let waited = |since| Examination {
978 due: 1,
979 oldest_due_since: Some(since),
980 ..sound()
981 };
982 let ok = waited(datetime!(2026-10-25 11:00 UTC));
983 assert_eq!(
984 problems(&tenant(Ok(ok), false), HOUR, NOW),
985 Vec::<String>::new()
986 );
987 let stalled = waited(datetime!(2026-10-25 10:59:59 UTC));
988 let found = problems(&tenant(Ok(stalled), false), HOUR, NOW);
989 assert_eq!(found.len(), 1);
990 assert!(found[0].contains("sweep"), "{found:?}");
991 }
992
993 #[test]
994 fn check_reports_each_tenant_and_a_summary_and_exits_1_on_a_problem() {
995 let s = setup();
996 let id = s.create("acme");
997 let out = s.run(Command::Check).expect("nothing wrong");
998 assert_eq!(out.len(), 2);
999 assert_eq!(out[0]["tenant"], id.as_str());
1000 assert_eq!(out[0]["problems"], json!([]));
1001 assert_eq!(out[1]["tenants"], 1);
1002 assert_eq!(out[1]["problems"], 0);
1003
1004 let tenant = TenantId::parse(&id).expect("id");
1005 let version = {
1006 let tenants = s.tenants();
1007 let store = tenants.store(tenant).expect("store");
1008 let (found, body) = vcon(UUID, "kept");
1009 let Ok(crate::store::Created::New(v)) =
1010 store.create(&Caller { key: KeyId([1; 8]) }, &found, &body)
1011 else {
1012 panic!("stored")
1013 };
1014 v
1015 };
1016 let objects = s.config.data_dir.join("tenants").join(&id).join("objects");
1017 std::fs::remove_file(objects.join(version.to_string())).expect("remove");
1018 std::fs::write(objects.join(format!("{version}.tmp")), b"x").expect("leftover");
1019 let mut printed = Vec::new();
1020 let found = run(&s.config, &Command::Check, &mut printed);
1021 assert!(
1022 matches!(found, Err(CommandError::CheckFound(1))),
1023 "{found:?}"
1024 );
1025 let lines: Vec<Value> = String::from_utf8(printed)
1026 .expect("text")
1027 .lines()
1028 .map(|l| serde_json::from_str(l).expect("json"))
1029 .collect();
1030 assert_eq!(lines[0]["damaged"], json!([version.to_string()]));
1031 assert_eq!(lines[0]["leftovers"], json!([format!("{version}.tmp")]));
1032 assert_eq!(lines[1]["problems"], 1);
1033 assert!(
1034 objects.join(format!("{version}.tmp")).exists(),
1035 "check reports leftovers; it does not remove them"
1036 );
1037 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
1038 assert!(matches!(
1039 s.run(Command::Check),
1040 Err(CommandError::DataDir(LockError::InUse(Sharing::Alone)))
1041 ));
1042 drop(server);
1043 }
1044 }
1045
1046 #[test]
1049 fn beside_a_server_only_the_beside_commands_run() {
1050 let s = setup();
1051 let tenant = s.create("acme");
1052 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
1053 for command in [
1054 Command::TenantCreate {
1055 name: "beta".to_owned(),
1056 retention: None,
1057 },
1058 Command::TenantRemove {
1059 id: tenant.clone(),
1060 force: true,
1061 override_holds: false,
1062 },
1063 ] {
1064 assert!(
1065 matches!(
1066 s.run(command.clone()),
1067 Err(CommandError::DataDir(LockError::InUse(Sharing::Alone)))
1068 ),
1069 "{command:?}"
1070 );
1071 }
1072 s.run(Command::TenantList).expect("beside");
1073 s.run(Command::TenantSetRetention {
1074 id: tenant.clone(),
1075 retention: "1y".to_owned(),
1076 })
1077 .expect("beside");
1078 s.run(Command::KeyList { tenant: None }).expect("beside");
1079 s.run(Command::AuditExport {
1080 log: Log::Admin,
1081 since: None,
1082 })
1083 .expect("beside");
1084 drop(server);
1085 assert_eq!(s.run(Command::TenantList).expect("listed").len(), 1);
1086 }
1087
1088 #[test]
1091 fn only_a_command_that_runs_alone_recovers() {
1092 let s = setup();
1093 s.create("acme");
1094 let in_progress = TenantId::generate().to_string();
1095 std::fs::create_dir(s.config.data_dir.join("tenants").join(&in_progress))
1096 .expect("a directory with no row");
1097 s.run(Command::TenantList).expect("listed");
1098 s.run(Command::KeyList { tenant: None }).expect("listed");
1099 assert!(
1100 s.tenant_dirs().contains(&in_progress),
1101 "left by beside commands"
1102 );
1103 s.create("beta");
1104 assert!(
1105 !s.tenant_dirs().contains(&in_progress),
1106 "removed by an alone command"
1107 );
1108 }
1109
1110 #[test]
1111 fn a_wrong_master_key_is_named() {
1112 let s = setup();
1113 s.create("acme");
1114 std::fs::write(&s.config.master_key, [0x5b; 32]).expect("another key");
1115 let error = s.run(Command::TenantList).expect_err("refused");
1116 assert!(matches!(error, CommandError::WrongMasterKey), "{error:?}");
1117 assert!(error.to_string().contains("master key"), "{error}");
1118 std::fs::write(&s.config.master_key, [0x5a; 31]).expect("short key");
1119 let error = s.run(Command::TenantList).expect_err("refused");
1120 assert!(matches!(error, CommandError::MasterKey(_)), "{error:?}");
1121 }
1122
1123 #[test]
1124 fn each_error_says_what_went_wrong() {
1125 for (error, words) in [
1126 (CommandError::WrongMasterKey, "master key"),
1127 (CommandError::BadPeriod("7w".to_owned()), "7w"),
1128 (CommandError::UnknownKey, "no key"),
1129 (CommandError::Tenant(TenantError::NotFound), "no tenant"),
1130 (CommandError::Tenant(TenantError::NameTaken), "name"),
1131 (CommandError::Tenant(TenantError::BadName), "name"),
1132 (CommandError::Tenant(TenantError::HoldsVcons), "--force"),
1133 (
1134 CommandError::Tenant(TenantError::UnderHold),
1135 "--override-holds",
1136 ),
1137 (CommandError::Tenant(TenantError::BadScopes), "scope"),
1138 (
1139 CommandError::Tenant(TenantError::LimitReached),
1140 "rekey master",
1141 ),
1142 (CommandError::Tenant(TenantError::AuditFailed), "audit"),
1143 (
1144 CommandError::DataDir(LockError::InUse(Sharing::Alone)),
1145 "in use",
1146 ),
1147 (CommandError::Output(io::Error::other("closed")), "output"),
1148 ] {
1149 let text = error.to_string();
1150 assert!(text.contains(words), "{error:?}: {text}");
1151 }
1152 }
1153}