1use rusqlite::OptionalExtension;
6use time::OffsetDateTime;
7use time::format_description::well_known::Rfc3339;
8
9use super::{TenantError, Tenants, io, stored_retention};
10use crate::audit::Action;
11use crate::clock::from_nanos;
12use crate::envelope::{self, Context};
13use crate::ids::TenantId;
14use crate::retention::Retention;
15use crate::store::Examination;
16
17#[derive(Debug)]
19pub struct TenantExamination {
20 pub id: TenantId,
21 pub name: String,
22 pub rotation_stopped: bool,
24 pub store: Result<Examination, TenantError>,
26}
27
28#[derive(Debug)]
30pub struct RegistryExamination {
31 pub tenants: Vec<TenantExamination>,
32 pub removed_since_master: u64,
35 pub oldest_removed_since_master: Option<OffsetDateTime>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub struct KeyOrder {
43 pub wrapped_elsewhere: u64,
45 pub rotations_stopped: u64,
47 pub data_under_other_keys: u64,
50}
51
52impl KeyOrder {
53 pub fn is_sound(&self) -> bool {
54 *self == KeyOrder::default()
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub struct RotationBacklog {
63 pub tenants_with_purges: u64,
64 pub purges: u64,
65 pub oldest_purge: Option<OffsetDateTime>,
66 pub removals: u64,
67 pub oldest_removal: Option<OffsetDateTime>,
68}
69
70impl Tenants {
71 pub fn key_order(&self) -> Result<KeyOrder, TenantError> {
73 let rows: Vec<([u8; 16], Vec<u8>, bool)> = self
74 .conn()
75 .prepare(
76 "SELECT id, wrapped_tenant_key, pending_wrapped_tenant_key IS NOT NULL FROM tenant",
77 )
78 .map_err(io)?
79 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
80 .map_err(io)?
81 .collect::<Result<_, _>>()
82 .map_err(io)?;
83 let mut order = KeyOrder::default();
84 for (id, wrapped, pending) in rows {
85 let id = TenantId(id);
86 if envelope::unwrap(&self.master, &Context::Tenant { tenant: id }, &wrapped).is_err() {
87 order.wrapped_elsewhere += 1;
88 continue;
89 }
90 if pending {
91 order.rotations_stopped += 1;
92 }
93 let elsewhere = match self.store(id) {
94 Ok(store) => store.rows_under_other_keys().map_err(io)? > 0,
95 Err(_) => true,
96 };
97 if elsewhere {
98 order.data_under_other_keys += 1;
99 }
100 }
101 Ok(order)
102 }
103
104 pub fn rotation_backlog(&self) -> Result<RotationBacklog, TenantError> {
107 let rows: Vec<([u8; 16], i64)> = self
108 .conn()
109 .prepare("SELECT id, key_since FROM tenant")
110 .map_err(io)?
111 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
112 .map_err(io)?
113 .collect::<Result<_, _>>()
114 .map_err(io)?;
115 let mut backlog = RotationBacklog::default();
116 for (id, key_since) in rows {
117 let key_since = from_nanos(key_since).ok_or(TenantError::Io)?;
118 let (purges, oldest) = self
119 .store(TenantId(id))?
120 .purges_since(key_since)
121 .map_err(io)?;
122 if purges > 0 {
123 backlog.tenants_with_purges += 1;
124 backlog.purges += purges;
125 backlog.oldest_purge = earliest(backlog.oldest_purge, oldest);
126 }
127 }
128 (backlog.removals, backlog.oldest_removal) = self.removals_since_master()?;
129 Ok(backlog)
130 }
131
132 fn removals_since_master(&self) -> Result<(u64, Option<OffsetDateTime>), TenantError> {
134 let (since, removals) = {
135 let conn = self.conn();
136 let since: Option<i64> = conn
137 .query_row(
138 "SELECT since FROM master_key_use WHERE master_key_id = ?1",
139 [self.master.id().bytes()],
140 |r| r.get(0),
141 )
142 .optional()
143 .map_err(io)?;
144 let removals: Vec<String> = conn
145 .prepare("SELECT at FROM audit WHERE action = ?1 AND status = 204")
146 .map_err(io)?
147 .query_map([Action::TenantRemove.as_str()], |r| r.get(0))
148 .map_err(io)?
149 .collect::<Result<_, _>>()
150 .map_err(io)?;
151 (since, removals)
152 };
153 let since = since
154 .map(|s| from_nanos(s).ok_or(TenantError::Io))
155 .transpose()?;
156 let (mut count, mut oldest) = (0, None);
157 for at in removals {
158 let at = OffsetDateTime::parse(&at, &Rfc3339).map_err(io)?;
159 if since.is_none_or(|since| at >= since) {
160 count += 1;
161 oldest = earliest(oldest, Some(at));
162 }
163 }
164 Ok((count, oldest))
165 }
166
167 pub fn examine(
170 &self,
171 default: Retention,
172 now: OffsetDateTime,
173 ) -> Result<RegistryExamination, TenantError> {
174 type Row = ([u8; 16], String, Option<String>, i64, bool);
175 let rows: Vec<Row> = self
176 .conn()
177 .prepare(
178 "SELECT id, name, retention, key_since, pending_wrapped_tenant_key IS NOT NULL
179 FROM tenant ORDER BY name",
180 )
181 .map_err(io)?
182 .query_map([], |r| {
183 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
184 })
185 .map_err(io)?
186 .collect::<Result<_, _>>()
187 .map_err(io)?;
188 let time = |nanos| from_nanos(nanos).ok_or(TenantError::Io);
189 let mut tenants = Vec::new();
190 for (id, name, retention, key_since, rotation_stopped) in rows {
191 let id = TenantId(id);
192 let retention = stored_retention(retention)?.unwrap_or(default);
193 let key_since = time(key_since)?;
194 let store = self
195 .store(id)
196 .and_then(|store| store.examine(retention, key_since, now).map_err(io));
197 tenants.push(TenantExamination {
198 id,
199 name,
200 rotation_stopped,
201 store,
202 });
203 }
204 let (removed_since_master, oldest_removed_since_master) = self.removals_since_master()?;
205 let found = RegistryExamination {
206 tenants,
207 removed_since_master,
208 oldest_removed_since_master,
209 };
210 Ok(found)
211 }
212}
213
214fn earliest(a: Option<OffsetDateTime>, b: Option<OffsetDateTime>) -> Option<OffsetDateTime> {
216 match (a, b) {
217 (Some(a), Some(b)) => Some(a.min(b)),
218 (a, b) => a.or(b),
219 }
220}
221
222#[cfg(test)]
223mod examine_tests {
224 use time::macros::datetime;
225
226 use super::super::tests::fixture;
227 use super::*;
228 use crate::audit::Actor;
229 use crate::store::Caller;
230 use crate::store::testing::vcon;
231
232 const CALLER: Caller = Caller {
233 key: crate::ids::KeyId([7; 8]),
234 };
235
236 #[test]
237 fn each_tenant_is_examined_under_its_own_period_or_the_default() {
238 let f = fixture();
239 let own = f
240 .tenants
241 .create(Actor::Cli, "own", Some(Retention::Days(1)))
242 .expect("made");
243 let default = f.tenants.create(Actor::Cli, "default", None).expect("made");
244 for id in [own, default] {
245 let store = f.tenants.store(id).expect("store");
246 let (found, body) = vcon("01a07da8-c2bb-83e5-b9a2-279e0d16bc46", "kept");
247 store.create(&CALLER, &found, &body).expect("stored");
248 store
249 .delete(&CALLER, found.uuid, Retention::Years(7))
250 .expect("deleted");
251 }
252 let found = f
253 .tenants
254 .examine(Retention::Years(7), datetime!(2026-10-30 12:00 UTC))
255 .expect("examined");
256 let by_name = |name: &str| found.tenants.iter().find(|t| t.name == name).expect(name);
257 let own = by_name("own");
258 assert_eq!(
259 own.store.as_ref().expect("opens").due,
260 1,
261 "a day has passed"
262 );
263 assert!(!own.rotation_stopped);
264 assert_eq!(by_name("default").store.as_ref().expect("opens").due, 0);
265 assert_eq!(found.tenants.len(), 2);
266 }
267
268 #[test]
269 fn a_rotation_stopped_part_way_is_reported_and_a_catalog_that_does_not_open_too() {
270 let f = fixture();
271 let id = f.tenants.create(Actor::Cli, "acme", None).expect("made");
272 *f.tenants.hooks.rekey_stop.lock().expect("hook") =
273 Some(super::super::rekey::RekeyStep::Reencrypted);
274 assert_eq!(f.tenants.rekey_tenant(Actor::Cli, id), Err(TenantError::Io));
275 let found = f
276 .tenants
277 .examine(Retention::Years(7), datetime!(2026-10-23 12:00 UTC))
278 .expect("examined");
279 let acme = &found.tenants[0];
280 assert!(acme.rotation_stopped);
281 assert!(acme.store.is_err(), "its catalog is under the pending key");
282 }
283
284 mod key_order {
285 use super::*;
286 use crate::envelope::{self, Context, Key};
287 use crate::tenants::rekey::RekeyStep;
288
289 fn with_vcons(f: &super::super::super::tests::Fixture) -> TenantId {
290 let id = f.tenants.create(Actor::Cli, "acme", None).expect("made");
291 let store = f.tenants.store(id).expect("store");
292 for n in 0..3 {
293 let (found, body) = vcon(&format!("01a07da8-c2bb-83e5-b9a2-279e0d16bc4{n}"), "k");
294 store.create(&CALLER, &found, &body).expect("stored");
295 }
296 id
297 }
298
299 #[test]
300 fn a_sound_registry_is_in_order() {
301 let f = fixture();
302 with_vcons(&f);
303 assert!(f.tenants.key_order().expect("read").is_sound());
304 }
305
306 #[test]
307 fn a_rotation_stopped_at_any_step_is_out_of_order() {
308 for (step, data) in [
309 (RekeyStep::Pending, 0),
310 (RekeyStep::Batch, 1),
311 (RekeyStep::Rewrapped, 1),
312 (RekeyStep::Reencrypted, 1),
313 ] {
314 let f = fixture();
315 let id = with_vcons(&f);
316 *f.tenants.hooks.rekey_stop.lock().expect("hook") = Some(step);
317 assert!(f.tenants.rekey_in_batches(Actor::Cli, id, 2).is_err());
318 assert_eq!(
319 f.tenants.key_order().expect("read"),
320 KeyOrder {
321 wrapped_elsewhere: 0,
322 rotations_stopped: 1,
323 data_under_other_keys: data,
324 },
325 "{step:?}"
326 );
327 f.tenants.rekey_tenant(Actor::Cli, id).expect("finished");
328 assert!(f.tenants.key_order().expect("read").is_sound(), "{step:?}");
329 }
330 }
331
332 #[test]
333 fn a_tenant_key_wrapped_under_another_key_is_out_of_order() {
334 let f = fixture();
335 let id = with_vcons(&f);
336 let other = Key::generate().expect("key");
337 let foreign =
338 envelope::wrap(&other, &Context::Tenant { tenant: id }, &f.tenant_key(id));
339 f.conn()
340 .execute(
341 "UPDATE tenant SET wrapped_tenant_key = ?1 WHERE id = ?2",
342 rusqlite::params![foreign, id.0],
343 )
344 .expect("rewritten");
345 f.tenants.close(id);
346 assert_eq!(f.tenants.key_order().expect("read").wrapped_elsewhere, 1);
347 }
348 }
349
350 #[test]
351 fn the_rotation_backlog_counts_purges_and_removals_from_the_logs() {
352 let f = fixture();
353 let purging = f
354 .tenants
355 .create(Actor::Cli, "purging", Some(Retention::Days(0)))
356 .expect("made");
357 f.tenants.create(Actor::Cli, "quiet", None).expect("made");
358 let gone = f.tenants.create(Actor::Cli, "gone", None).expect("made");
359 let store = f.tenants.store(purging).expect("store");
360 for (n, at) in [
361 (0, datetime!(2026-10-24 10:00 UTC)),
362 (1, datetime!(2026-10-24 11:00 UTC)),
363 ] {
364 f.clock.set(at);
365 let (found, body) = vcon(&format!("01a07da8-c2bb-83e5-b9a2-279e0d16bc4{n}"), "k");
366 store.create(&CALLER, &found, &body).expect("stored");
367 store
368 .delete(&CALLER, found.uuid, Retention::Days(0))
369 .expect("deleted and purged");
370 }
371 drop(store);
372 f.clock.set(datetime!(2026-10-24 12:00 UTC));
373 f.tenants
374 .remove(Actor::Cli, gone, crate::tenants::Removal::Empty)
375 .expect("removed");
376 assert_eq!(
377 f.tenants.rotation_backlog().expect("read"),
378 RotationBacklog {
379 tenants_with_purges: 1,
380 purges: 2,
381 oldest_purge: Some(datetime!(2026-10-24 10:00 UTC)),
382 removals: 1,
383 oldest_removal: Some(datetime!(2026-10-24 12:00 UTC)),
384 }
385 );
386 }
387
388 #[test]
389 fn removals_since_the_master_key_was_rotated_are_counted() {
390 let f = fixture();
391 let gone = f.tenants.create(Actor::Cli, "gone", None).expect("made");
392 f.tenants.create(Actor::Cli, "kept", None).expect("made");
393 f.clock.set(datetime!(2026-10-24 08:00 UTC));
394 f.tenants
395 .remove(Actor::Cli, gone, crate::tenants::Removal::Empty)
396 .expect("removed");
397 f.conn()
399 .execute_batch(
400 "INSERT INTO audit (at, actor, action, target, version, status, count)
401 VALUES ('2026-10-01T00:00:00Z', 'cli', 'tenant_remove', NULL, NULL, 204, 1),
402 ('2026-10-24T09:00:00Z', 'cli', 'tenant_remove', NULL, NULL, 409, 1);",
403 )
404 .expect("older records");
405 let found = f
406 .tenants
407 .examine(Retention::Years(7), datetime!(2026-10-25 12:00 UTC))
408 .expect("examined");
409 assert_eq!(found.removed_since_master, 1);
410 assert_eq!(
411 found.oldest_removed_since_master,
412 Some(datetime!(2026-10-24 08:00 UTC))
413 );
414 assert_eq!(found.tenants.len(), 1);
415 }
416}