1use std::path::Path;
41use std::sync::{Mutex, MutexGuard};
42
43use rusqlite::{Connection, OptionalExtension};
44
45use crate::error::ApiError;
46
47pub const STORE_FILE_NAME: &str = "tenancy.db";
49
50pub fn ownerless_readable(operator_flag: Option<bool>, active_keys: usize) -> bool {
61 match operator_flag {
62 Some(deny) => !deny,
63 None => active_keys <= 1,
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct IndexRecord {
70 pub index_id: String,
72 pub owner_key_id: String,
74 pub created_at: i64,
76 pub expires_at: Option<i64>,
78}
79
80impl IndexRecord {
81 pub fn is_expired(&self, now: i64) -> bool {
83 self.expires_at.is_some_and(|expires| expires <= now)
84 }
85}
86
87#[derive(Debug)]
93pub struct Tenancy {
94 conn: Mutex<Connection>,
95}
96
97const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS index_owners (
98 index_id TEXT PRIMARY KEY,
99 owner_key_id TEXT NOT NULL,
100 created_at INTEGER NOT NULL,
101 expires_at INTEGER
102);";
103
104impl Tenancy {
105 pub fn open(path: &Path) -> Result<Self, ApiError> {
107 let conn = Connection::open(path).map_err(|err| {
108 ApiError::Internal(format!(
109 "cannot open tenancy store {}: {err}",
110 path.display()
111 ))
112 })?;
113 conn.execute_batch(&format!(
114 "PRAGMA journal_mode = WAL;
115 PRAGMA busy_timeout = 5000;
116 {SCHEMA}"
117 ))
118 .map_err(|err| ApiError::Internal(format!("cannot initialise tenancy store: {err}")))?;
119 Ok(Tenancy {
120 conn: Mutex::new(conn),
121 })
122 }
123
124 #[cfg(test)]
126 fn in_memory() -> Result<Self, ApiError> {
127 let conn = Connection::open_in_memory().map_err(|err| {
128 ApiError::Internal(format!("cannot open in-memory tenancy store: {err}"))
129 })?;
130 conn.execute_batch(SCHEMA)
131 .map_err(|err| ApiError::Internal(format!("cannot initialise tenancy store: {err}")))?;
132 Ok(Tenancy {
133 conn: Mutex::new(conn),
134 })
135 }
136
137 pub fn record(
140 &self,
141 index_id: &str,
142 owner_key_id: &str,
143 created_at: i64,
144 expires_at: Option<i64>,
145 ) -> Result<(), ApiError> {
146 let conn = self.lock()?;
147 conn.execute(
148 "INSERT INTO index_owners (index_id, owner_key_id, created_at, expires_at)
149 VALUES (?1, ?2, ?3, ?4)",
150 rusqlite::params![index_id, owner_key_id, created_at, expires_at],
151 )
152 .map_err(|err| ApiError::Internal(format!("cannot record index owner: {err}")))?;
153 Ok(())
154 }
155
156 pub fn lookup(&self, index_id: &str) -> Result<Option<IndexRecord>, ApiError> {
158 let conn = self.lock()?;
159 conn.query_row(
160 "SELECT index_id, owner_key_id, created_at, expires_at
161 FROM index_owners WHERE index_id = ?1",
162 rusqlite::params![index_id],
163 |row| {
164 Ok(IndexRecord {
165 index_id: row.get(0)?,
166 owner_key_id: row.get(1)?,
167 created_at: row.get(2)?,
168 expires_at: row.get(3)?,
169 })
170 },
171 )
172 .optional()
173 .map_err(|err| ApiError::Internal(format!("cannot read tenancy store: {err}")))
174 }
175
176 pub fn count_active_for_owner(&self, owner_key_id: &str, now: i64) -> Result<usize, ApiError> {
183 let conn = self.lock()?;
184 let count: i64 = conn
185 .query_row(
186 "SELECT COUNT(*) FROM index_owners
187 WHERE owner_key_id = ?1
188 AND (expires_at IS NULL OR expires_at > ?2)",
189 rusqlite::params![owner_key_id, now],
190 |row| row.get(0),
191 )
192 .map_err(|err| ApiError::Internal(format!("cannot read tenancy store: {err}")))?;
193 Ok(count.max(0) as usize)
194 }
195
196 pub fn remove(&self, index_id: &str) -> Result<(), ApiError> {
198 let conn = self.lock()?;
199 conn.execute(
200 "DELETE FROM index_owners WHERE index_id = ?1",
201 rusqlite::params![index_id],
202 )
203 .map_err(|err| ApiError::Internal(format!("cannot update tenancy store: {err}")))?;
204 Ok(())
205 }
206
207 pub fn prune_expired(&self, now: i64) -> Result<Vec<String>, ApiError> {
211 let conn = self.lock()?;
212 let mut statement = conn
213 .prepare(
214 "SELECT index_id FROM index_owners
215 WHERE expires_at IS NOT NULL AND expires_at <= ?1",
216 )
217 .map_err(|err| ApiError::Internal(format!("cannot read tenancy store: {err}")))?;
218 let rows = statement
219 .query_map(rusqlite::params![now], |row| row.get::<_, String>(0))
220 .map_err(|err| ApiError::Internal(format!("cannot read tenancy store: {err}")))?;
221 let mut expired = Vec::new();
222 for row in rows {
223 expired.push(
224 row.map_err(|err| {
225 ApiError::Internal(format!("cannot read tenancy store: {err}"))
226 })?,
227 );
228 }
229 drop(statement);
230 for index_id in &expired {
231 conn.execute(
232 "DELETE FROM index_owners WHERE index_id = ?1",
233 rusqlite::params![index_id],
234 )
235 .map_err(|err| ApiError::Internal(format!("cannot update tenancy store: {err}")))?;
236 }
237 Ok(expired)
238 }
239
240 fn lock(&self) -> Result<MutexGuard<'_, Connection>, ApiError> {
241 self.conn
242 .lock()
243 .map_err(|_| ApiError::Internal("tenancy store lock poisoned".to_string()))
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn tenancy() -> Tenancy {
252 Tenancy::in_memory().expect("in-memory tenancy")
253 }
254
255 #[test]
259 fn ownerless_default_turns_on_the_active_key_count() {
260 assert!(ownerless_readable(None, 0));
261 assert!(ownerless_readable(None, 1), "single-operator upgrade path");
262 assert!(!ownerless_readable(None, 2), "a second key ends the legacy posture");
263 assert!(!ownerless_readable(None, 25));
264 }
265
266 #[test]
269 fn ownerless_operator_flag_overrides_the_default() {
270 assert!(!ownerless_readable(Some(true), 1), "explicit deny binds even a lone key");
271 assert!(!ownerless_readable(Some(true), 0));
272 assert!(ownerless_readable(Some(false), 2), "explicit allow keeps legacy indexes readable");
273 assert!(ownerless_readable(Some(false), 25));
274 }
275
276 #[test]
277 fn record_and_lookup_round_trip() {
278 let tenancy = tenancy();
279 tenancy
280 .record("idx-1", "tenant-a", 1_700_000_000, Some(1_700_086_400))
281 .expect("record");
282 let found = tenancy.lookup("idx-1").expect("lookup").expect("present");
283 assert_eq!(
284 found,
285 IndexRecord {
286 index_id: "idx-1".to_string(),
287 owner_key_id: "tenant-a".to_string(),
288 created_at: 1_700_000_000,
289 expires_at: Some(1_700_086_400),
290 }
291 );
292 assert!(tenancy.lookup("idx-2").expect("lookup").is_none());
293 }
294
295 #[test]
296 fn record_without_expiry_never_expires() {
297 let tenancy = tenancy();
298 tenancy.record("idx-1", "tenant-a", 100, None).expect("record");
299 let found = tenancy.lookup("idx-1").expect("lookup").expect("present");
300 assert!(!found.is_expired(i64::MAX));
301 }
302
303 #[test]
304 fn expiry_boundary_is_inclusive() {
305 let record = IndexRecord {
306 index_id: "idx".to_string(),
307 owner_key_id: "tenant-a".to_string(),
308 created_at: 100,
309 expires_at: Some(200),
310 };
311 assert!(!record.is_expired(199));
312 assert!(record.is_expired(200), "at expires_at the index is refused");
313 assert!(record.is_expired(201));
314 }
315
316 #[test]
317 fn count_active_for_owner_counts_only_live_indexes_of_that_owner() {
318 let tenancy = tenancy();
319 tenancy.record("live", "tenant-a", 10, None).expect("record");
320 tenancy
321 .record("fresh", "tenant-a", 10, Some(500))
322 .expect("record");
323 tenancy
324 .record("stale", "tenant-a", 10, Some(50))
325 .expect("record");
326 tenancy
327 .record("boundary", "tenant-a", 10, Some(100))
328 .expect("record");
329 tenancy.record("other", "tenant-b", 10, None).expect("record");
330
331 assert_eq!(
335 tenancy.count_active_for_owner("tenant-a", 100).expect("count"),
336 2
337 );
338 assert_eq!(
339 tenancy.count_active_for_owner("tenant-b", 100).expect("count"),
340 1
341 );
342 assert_eq!(
343 tenancy.count_active_for_owner("ghost", 100).expect("count"),
344 0
345 );
346 }
347
348 #[test]
349 fn remove_drops_the_record() {
350 let tenancy = tenancy();
351 tenancy.record("idx-1", "tenant-a", 100, None).expect("record");
352 tenancy.remove("idx-1").expect("remove");
353 assert!(tenancy.lookup("idx-1").expect("lookup").is_none());
354 tenancy.remove("idx-1").expect("remove again");
356 }
357
358 #[test]
359 fn prune_expired_returns_only_the_expired() {
360 let tenancy = tenancy();
361 tenancy.record("old", "tenant-a", 10, Some(50)).expect("record");
362 tenancy
363 .record("fresh", "tenant-a", 10, Some(500))
364 .expect("record");
365 tenancy.record("forever", "tenant-b", 10, None).expect("record");
366
367 let pruned = tenancy.prune_expired(100).expect("prune");
368 assert_eq!(pruned, vec!["old".to_string()]);
369 assert!(tenancy.lookup("old").expect("lookup").is_none());
370 assert!(tenancy.lookup("fresh").expect("lookup").is_some());
371 assert!(tenancy.lookup("forever").expect("lookup").is_some());
372 assert!(tenancy.prune_expired(100).expect("prune").is_empty());
374 }
375
376 #[test]
377 fn a_file_store_survives_reopening() {
378 let dir = tempfile::tempdir().expect("temp dir");
379 let path = dir.path().join("tenancy.db");
380 {
381 let tenancy = Tenancy::open(&path).expect("open");
382 tenancy
383 .record("idx-1", "tenant-a", 100, None)
384 .expect("record");
385 }
386 let reopened = Tenancy::open(&path).expect("reopen");
387 let found = reopened.lookup("idx-1").expect("lookup").expect("present");
388 assert_eq!(found.owner_key_id, "tenant-a");
389 }
390}