use std::path::Path;
use std::sync::{Mutex, MutexGuard};
use rusqlite::{Connection, OptionalExtension};
use crate::error::ApiError;
pub const STORE_FILE_NAME: &str = "tenancy.db";
pub fn ownerless_readable(operator_flag: Option<bool>, active_keys: usize) -> bool {
match operator_flag {
Some(deny) => !deny,
None => active_keys <= 1,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexRecord {
pub index_id: String,
pub owner_key_id: String,
pub created_at: i64,
pub expires_at: Option<i64>,
}
impl IndexRecord {
pub fn is_expired(&self, now: i64) -> bool {
self.expires_at.is_some_and(|expires| expires <= now)
}
}
#[derive(Debug)]
pub struct Tenancy {
conn: Mutex<Connection>,
}
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS index_owners (
index_id TEXT PRIMARY KEY,
owner_key_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER
);";
impl Tenancy {
pub fn open(path: &Path) -> Result<Self, ApiError> {
let conn = Connection::open(path).map_err(|err| {
ApiError::Internal(format!(
"cannot open tenancy store {}: {err}",
path.display()
))
})?;
conn.execute_batch(&format!(
"PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
{SCHEMA}"
))
.map_err(|err| ApiError::Internal(format!("cannot initialise tenancy store: {err}")))?;
Ok(Tenancy {
conn: Mutex::new(conn),
})
}
#[cfg(test)]
fn in_memory() -> Result<Self, ApiError> {
let conn = Connection::open_in_memory().map_err(|err| {
ApiError::Internal(format!("cannot open in-memory tenancy store: {err}"))
})?;
conn.execute_batch(SCHEMA)
.map_err(|err| ApiError::Internal(format!("cannot initialise tenancy store: {err}")))?;
Ok(Tenancy {
conn: Mutex::new(conn),
})
}
pub fn record(
&self,
index_id: &str,
owner_key_id: &str,
created_at: i64,
expires_at: Option<i64>,
) -> Result<(), ApiError> {
let conn = self.lock()?;
conn.execute(
"INSERT INTO index_owners (index_id, owner_key_id, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![index_id, owner_key_id, created_at, expires_at],
)
.map_err(|err| ApiError::Internal(format!("cannot record index owner: {err}")))?;
Ok(())
}
pub fn lookup(&self, index_id: &str) -> Result<Option<IndexRecord>, ApiError> {
let conn = self.lock()?;
conn.query_row(
"SELECT index_id, owner_key_id, created_at, expires_at
FROM index_owners WHERE index_id = ?1",
rusqlite::params![index_id],
|row| {
Ok(IndexRecord {
index_id: row.get(0)?,
owner_key_id: row.get(1)?,
created_at: row.get(2)?,
expires_at: row.get(3)?,
})
},
)
.optional()
.map_err(|err| ApiError::Internal(format!("cannot read tenancy store: {err}")))
}
pub fn count_active_for_owner(&self, owner_key_id: &str, now: i64) -> Result<usize, ApiError> {
let conn = self.lock()?;
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM index_owners
WHERE owner_key_id = ?1
AND (expires_at IS NULL OR expires_at > ?2)",
rusqlite::params![owner_key_id, now],
|row| row.get(0),
)
.map_err(|err| ApiError::Internal(format!("cannot read tenancy store: {err}")))?;
Ok(count.max(0) as usize)
}
pub fn remove(&self, index_id: &str) -> Result<(), ApiError> {
let conn = self.lock()?;
conn.execute(
"DELETE FROM index_owners WHERE index_id = ?1",
rusqlite::params![index_id],
)
.map_err(|err| ApiError::Internal(format!("cannot update tenancy store: {err}")))?;
Ok(())
}
pub fn prune_expired(&self, now: i64) -> Result<Vec<String>, ApiError> {
let conn = self.lock()?;
let mut statement = conn
.prepare(
"SELECT index_id FROM index_owners
WHERE expires_at IS NOT NULL AND expires_at <= ?1",
)
.map_err(|err| ApiError::Internal(format!("cannot read tenancy store: {err}")))?;
let rows = statement
.query_map(rusqlite::params![now], |row| row.get::<_, String>(0))
.map_err(|err| ApiError::Internal(format!("cannot read tenancy store: {err}")))?;
let mut expired = Vec::new();
for row in rows {
expired.push(
row.map_err(|err| {
ApiError::Internal(format!("cannot read tenancy store: {err}"))
})?,
);
}
drop(statement);
for index_id in &expired {
conn.execute(
"DELETE FROM index_owners WHERE index_id = ?1",
rusqlite::params![index_id],
)
.map_err(|err| ApiError::Internal(format!("cannot update tenancy store: {err}")))?;
}
Ok(expired)
}
fn lock(&self) -> Result<MutexGuard<'_, Connection>, ApiError> {
self.conn
.lock()
.map_err(|_| ApiError::Internal("tenancy store lock poisoned".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tenancy() -> Tenancy {
Tenancy::in_memory().expect("in-memory tenancy")
}
#[test]
fn ownerless_default_turns_on_the_active_key_count() {
assert!(ownerless_readable(None, 0));
assert!(ownerless_readable(None, 1), "single-operator upgrade path");
assert!(!ownerless_readable(None, 2), "a second key ends the legacy posture");
assert!(!ownerless_readable(None, 25));
}
#[test]
fn ownerless_operator_flag_overrides_the_default() {
assert!(!ownerless_readable(Some(true), 1), "explicit deny binds even a lone key");
assert!(!ownerless_readable(Some(true), 0));
assert!(ownerless_readable(Some(false), 2), "explicit allow keeps legacy indexes readable");
assert!(ownerless_readable(Some(false), 25));
}
#[test]
fn record_and_lookup_round_trip() {
let tenancy = tenancy();
tenancy
.record("idx-1", "tenant-a", 1_700_000_000, Some(1_700_086_400))
.expect("record");
let found = tenancy.lookup("idx-1").expect("lookup").expect("present");
assert_eq!(
found,
IndexRecord {
index_id: "idx-1".to_string(),
owner_key_id: "tenant-a".to_string(),
created_at: 1_700_000_000,
expires_at: Some(1_700_086_400),
}
);
assert!(tenancy.lookup("idx-2").expect("lookup").is_none());
}
#[test]
fn record_without_expiry_never_expires() {
let tenancy = tenancy();
tenancy.record("idx-1", "tenant-a", 100, None).expect("record");
let found = tenancy.lookup("idx-1").expect("lookup").expect("present");
assert!(!found.is_expired(i64::MAX));
}
#[test]
fn expiry_boundary_is_inclusive() {
let record = IndexRecord {
index_id: "idx".to_string(),
owner_key_id: "tenant-a".to_string(),
created_at: 100,
expires_at: Some(200),
};
assert!(!record.is_expired(199));
assert!(record.is_expired(200), "at expires_at the index is refused");
assert!(record.is_expired(201));
}
#[test]
fn count_active_for_owner_counts_only_live_indexes_of_that_owner() {
let tenancy = tenancy();
tenancy.record("live", "tenant-a", 10, None).expect("record");
tenancy
.record("fresh", "tenant-a", 10, Some(500))
.expect("record");
tenancy
.record("stale", "tenant-a", 10, Some(50))
.expect("record");
tenancy
.record("boundary", "tenant-a", 10, Some(100))
.expect("record");
tenancy.record("other", "tenant-b", 10, None).expect("record");
assert_eq!(
tenancy.count_active_for_owner("tenant-a", 100).expect("count"),
2
);
assert_eq!(
tenancy.count_active_for_owner("tenant-b", 100).expect("count"),
1
);
assert_eq!(
tenancy.count_active_for_owner("ghost", 100).expect("count"),
0
);
}
#[test]
fn remove_drops_the_record() {
let tenancy = tenancy();
tenancy.record("idx-1", "tenant-a", 100, None).expect("record");
tenancy.remove("idx-1").expect("remove");
assert!(tenancy.lookup("idx-1").expect("lookup").is_none());
tenancy.remove("idx-1").expect("remove again");
}
#[test]
fn prune_expired_returns_only_the_expired() {
let tenancy = tenancy();
tenancy.record("old", "tenant-a", 10, Some(50)).expect("record");
tenancy
.record("fresh", "tenant-a", 10, Some(500))
.expect("record");
tenancy.record("forever", "tenant-b", 10, None).expect("record");
let pruned = tenancy.prune_expired(100).expect("prune");
assert_eq!(pruned, vec!["old".to_string()]);
assert!(tenancy.lookup("old").expect("lookup").is_none());
assert!(tenancy.lookup("fresh").expect("lookup").is_some());
assert!(tenancy.lookup("forever").expect("lookup").is_some());
assert!(tenancy.prune_expired(100).expect("prune").is_empty());
}
#[test]
fn a_file_store_survives_reopening() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("tenancy.db");
{
let tenancy = Tenancy::open(&path).expect("open");
tenancy
.record("idx-1", "tenant-a", 100, None)
.expect("record");
}
let reopened = Tenancy::open(&path).expect("reopen");
let found = reopened.lookup("idx-1").expect("lookup").expect("present");
assert_eq!(found.owner_key_id, "tenant-a");
}
}