use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use super::{OpenFailure, TenantError, Tenants, io, stored_retention};
use crate::audit::Action;
use crate::clock::from_nanos;
use crate::envelope::{self, Context};
use crate::ids::TenantId;
use crate::retention::Retention;
use crate::store::Examination;
#[derive(Debug)]
pub struct TenantExamination {
pub id: TenantId,
pub name: String,
pub rotation_stopped: bool,
pub store: Result<Examination, OpenFailure>,
}
#[derive(Debug)]
pub struct RegistryExamination {
pub tenants: Vec<TenantExamination>,
pub removed_since_master: u64,
pub oldest_removed_since_master: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct KeyOrder {
pub wrapped_elsewhere: u64,
pub rotations_stopped: u64,
pub data_under_other_keys: u64,
pub catalogs_unopened: u64,
pub unopened_error: Option<String>,
}
impl KeyOrder {
pub fn is_sound(&self) -> bool {
*self == KeyOrder::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RotationBacklog {
pub tenants_with_purges: u64,
pub purges: u64,
pub oldest_purge: Option<OffsetDateTime>,
pub removals: u64,
pub oldest_removal: Option<OffsetDateTime>,
}
impl Tenants {
pub fn key_order(&self) -> Result<KeyOrder, TenantError> {
let rows = self.registry.snapshot()?.tenants()?;
let mut order = KeyOrder::default();
for row in rows {
let (id, wrapped, pending) = (row.id, row.wrapped, row.pending.is_some());
if envelope::unwrap(&self.master, &Context::Tenant { tenant: id }, &wrapped).is_err() {
order.wrapped_elsewhere += 1;
continue;
}
if pending {
order.rotations_stopped += 1;
}
let elsewhere = match self.store_or_why(id) {
Ok(store) => store.rows_under_other_keys().map_err(io)? > 0,
Err(OpenFailure::WrongKey) => true,
Err(failure) => {
order.catalogs_unopened += 1;
if let OpenFailure::Access { error, .. } = failure {
order.unopened_error.get_or_insert(error.to_string());
}
false
}
};
if elsewhere {
order.data_under_other_keys += 1;
}
}
Ok(order)
}
pub fn rotation_backlog(&self) -> Result<RotationBacklog, TenantError> {
let rows = self.registry.snapshot()?.tenants()?;
let mut backlog = RotationBacklog::default();
for row in rows {
let key_since = from_nanos(row.key_since).ok_or(TenantError::Io)?;
let (purges, oldest) = self.store(row.id)?.purges_since(key_since).map_err(io)?;
if purges > 0 {
backlog.tenants_with_purges += 1;
backlog.purges += purges;
backlog.oldest_purge = earliest(backlog.oldest_purge, oldest);
}
}
(backlog.removals, backlog.oldest_removal) = self.removals_since_master()?;
Ok(backlog)
}
fn removals_since_master(&self) -> Result<(u64, Option<OffsetDateTime>), TenantError> {
let (since, removals) = {
let mut tx = self.registry.snapshot()?;
let since = tx.master_since(self.master.id())?;
let removals = tx.record_times(Action::TenantRemove, 204)?;
(since, removals)
};
let since = since
.map(|s| from_nanos(s).ok_or(TenantError::Io))
.transpose()?;
let (mut count, mut oldest) = (0, None);
for at in removals {
let at = OffsetDateTime::parse(&at, &Rfc3339).map_err(io)?;
if since.is_none_or(|since| at >= since) {
count += 1;
oldest = earliest(oldest, Some(at));
}
}
Ok((count, oldest))
}
pub fn examine(
&self,
default: Retention,
now: OffsetDateTime,
) -> Result<RegistryExamination, TenantError> {
let rows = self.registry.snapshot()?.tenants()?;
let time = |nanos| from_nanos(nanos).ok_or(TenantError::Io);
let mut tenants = Vec::new();
for row in rows {
let (id, name, retention, key_since) = (row.id, row.name, row.retention, row.key_since);
let rotation_stopped = row.pending.is_some();
let retention = super::kept_for(stored_retention(retention)?, default);
let key_since = time(key_since)?;
let store = self.store_or_why(id).and_then(|store| {
store
.examine(retention, key_since, now)
.map_err(|e| OpenFailure::Refused(io(e)))
});
tenants.push(TenantExamination {
id,
name,
rotation_stopped,
store,
});
}
let (removed_since_master, oldest_removed_since_master) = self.removals_since_master()?;
let found = RegistryExamination {
tenants,
removed_since_master,
oldest_removed_since_master,
};
Ok(found)
}
}
fn earliest(a: Option<OffsetDateTime>, b: Option<OffsetDateTime>) -> Option<OffsetDateTime> {
match (a, b) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
}
}
#[cfg(test)]
mod examine_tests {
use time::macros::datetime;
use super::super::tests::fixture;
use super::*;
use crate::audit::Actor;
use crate::store::Caller;
use crate::store::testing::vcon;
const CALLER: Caller = Caller {
key: crate::ids::KeyId([7; 8]),
};
#[test]
fn each_tenant_is_examined_under_its_own_period_or_the_default() {
let f = fixture();
let own = f
.tenants
.create(Actor::Cli, "own", Some(Retention::Days(1)))
.expect("made");
let default = f.tenants.create(Actor::Cli, "default", None).expect("made");
for id in [own, default] {
let store = f.tenants.store(id).expect("store");
let (found, body) = vcon("01a07da8-c2bb-83e5-b9a2-279e0d16bc46", "kept");
store.create(&CALLER, &found, &body).expect("stored");
store
.delete(&CALLER, found.uuid, Retention::Years(7))
.expect("deleted");
}
let found = f
.tenants
.examine(Retention::Years(7), datetime!(2026-10-30 12:00 UTC))
.expect("examined");
let by_name = |name: &str| found.tenants.iter().find(|t| t.name == name).expect(name);
let own = by_name("own");
assert_eq!(
own.store.as_ref().expect("opens").due,
1,
"a day has passed"
);
assert!(!own.rotation_stopped);
assert_eq!(by_name("default").store.as_ref().expect("opens").due, 0);
assert_eq!(found.tenants.len(), 2);
}
#[test]
fn a_rotation_stopped_part_way_is_reported_and_a_catalog_that_does_not_open_too() {
let f = fixture();
let id = f.tenants.create(Actor::Cli, "acme", None).expect("made");
*f.tenants.hooks.rekey_stop.lock().expect("hook") =
Some(super::super::rekey::RekeyStep::Reencrypted);
assert_eq!(f.tenants.rekey_tenant(Actor::Cli, id), Err(TenantError::Io));
let found = f
.tenants
.examine(Retention::Years(7), datetime!(2026-10-23 12:00 UTC))
.expect("examined");
let acme = &found.tenants[0];
assert!(acme.rotation_stopped);
assert!(
matches!(acme.store, Err(OpenFailure::WrongKey)),
"its catalog is under the pending key: {:?}",
acme.store
);
}
#[test]
fn a_catalog_the_process_may_not_open_is_an_access_failure_not_a_key_one() {
use std::os::unix::fs::PermissionsExt;
let f = fixture();
let id = f.tenants.create(Actor::Cli, "acme", None).expect("made");
let dir = f.dir.path().join("tenants").join(id.to_string());
let mode =
|m| std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(m)).expect("chmod");
mode(0o500);
let order = f.tenants.key_order();
let found = f
.tenants
.examine(Retention::Years(7), datetime!(2026-10-23 12:00 UTC));
mode(0o700);
let order = order.expect("read");
assert_eq!(order.data_under_other_keys, 0, "{order:?}");
assert_eq!(order.catalogs_unopened, 1, "{order:?}");
assert_eq!(
order.unopened_error.as_deref(),
Some("Permission denied (os error 13)")
);
assert!(!order.is_sound());
match &found.expect("examined").tenants[0].store {
Err(OpenFailure::Access { path, error }) => {
assert_eq!(path, &dir);
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
}
other => panic!("{other:?}"),
}
assert!(
f.tenants.key_order().expect("read").is_sound(),
"once allowed"
);
}
mod key_order {
use super::*;
use crate::envelope::{self, Context, Key};
use crate::tenants::rekey::RekeyStep;
fn with_vcons(f: &super::super::super::tests::Fixture) -> TenantId {
let id = f.tenants.create(Actor::Cli, "acme", None).expect("made");
let store = f.tenants.store(id).expect("store");
for n in 0..3 {
let (found, body) = vcon(&format!("01a07da8-c2bb-83e5-b9a2-279e0d16bc4{n}"), "k");
store.create(&CALLER, &found, &body).expect("stored");
}
id
}
#[test]
fn a_sound_registry_is_in_order() {
let f = fixture();
with_vcons(&f);
assert!(f.tenants.key_order().expect("read").is_sound());
}
#[test]
fn a_rotation_stopped_at_any_step_is_out_of_order() {
for (step, data) in [
(RekeyStep::Pending, 0),
(RekeyStep::Batch, 1),
(RekeyStep::Rewrapped, 1),
(RekeyStep::Reencrypted, 1),
] {
let f = fixture();
let id = with_vcons(&f);
*f.tenants.hooks.rekey_stop.lock().expect("hook") = Some(step);
assert!(f.tenants.rekey_in_batches(Actor::Cli, id, 2).is_err());
assert_eq!(
f.tenants.key_order().expect("read"),
KeyOrder {
wrapped_elsewhere: 0,
rotations_stopped: 1,
data_under_other_keys: data,
..KeyOrder::default()
},
"{step:?}"
);
f.tenants.rekey_tenant(Actor::Cli, id).expect("finished");
assert!(f.tenants.key_order().expect("read").is_sound(), "{step:?}");
}
}
#[test]
fn a_tenant_key_wrapped_under_another_key_is_out_of_order() {
let f = fixture();
let id = with_vcons(&f);
let other = Key::generate().expect("key");
let foreign =
envelope::wrap(&other, &Context::Tenant { tenant: id }, &f.tenant_key(id));
f.conn()
.execute(
"UPDATE tenant SET wrapped_tenant_key = ?1 WHERE id = ?2",
rusqlite::params![foreign, id.0],
)
.expect("rewritten");
f.tenants.close(id);
assert_eq!(f.tenants.key_order().expect("read").wrapped_elsewhere, 1);
}
}
#[test]
fn the_rotation_backlog_counts_purges_and_removals_from_the_logs() {
let f = fixture();
let purging = f
.tenants
.create(Actor::Cli, "purging", Some(Retention::Days(0)))
.expect("made");
f.tenants.create(Actor::Cli, "quiet", None).expect("made");
let gone = f.tenants.create(Actor::Cli, "gone", None).expect("made");
let store = f.tenants.store(purging).expect("store");
for (n, at) in [
(0, datetime!(2026-10-24 10:00 UTC)),
(1, datetime!(2026-10-24 11:00 UTC)),
] {
f.clock.set(at);
let (found, body) = vcon(&format!("01a07da8-c2bb-83e5-b9a2-279e0d16bc4{n}"), "k");
store.create(&CALLER, &found, &body).expect("stored");
store
.delete(&CALLER, found.uuid, Retention::Days(0))
.expect("deleted and purged");
}
drop(store);
f.clock.set(datetime!(2026-10-24 12:00 UTC));
f.tenants
.remove(Actor::Cli, gone, crate::tenants::Removal::Empty)
.expect("removed");
assert_eq!(
f.tenants.rotation_backlog().expect("read"),
RotationBacklog {
tenants_with_purges: 1,
purges: 2,
oldest_purge: Some(datetime!(2026-10-24 10:00 UTC)),
removals: 1,
oldest_removal: Some(datetime!(2026-10-24 12:00 UTC)),
}
);
}
#[test]
fn removals_since_the_master_key_was_rotated_are_counted() {
let f = fixture();
let gone = f.tenants.create(Actor::Cli, "gone", None).expect("made");
f.tenants.create(Actor::Cli, "kept", None).expect("made");
f.clock.set(datetime!(2026-10-24 08:00 UTC));
f.tenants
.remove(Actor::Cli, gone, crate::tenants::Removal::Empty)
.expect("removed");
f.conn()
.execute_batch(
"INSERT INTO audit (at, actor, action, target, version, status, count)
VALUES ('2026-10-01T00:00:00Z', 'cli', 'tenant_remove', NULL, NULL, 204, 1),
('2026-10-24T09:00:00Z', 'cli', 'tenant_remove', NULL, NULL, 409, 1);",
)
.expect("older records");
let found = f
.tenants
.examine(Retention::Years(7), datetime!(2026-10-25 12:00 UTC))
.expect("examined");
assert_eq!(found.removed_since_master, 1);
assert_eq!(
found.oldest_removed_since_master,
Some(datetime!(2026-10-24 08:00 UTC))
);
assert_eq!(found.tenants.len(), 1);
}
}