Skip to main content

codetopo_server/
tenancy.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! Index ownership and expiry (paid-path plan S1, gap G2).
6//!
7//! Before tenancy, any valid key could query any `index_id`: the handlers
8//! resolved `<data_dir>/<id>.db` straight from the URL and the caller's
9//! identity was used only for metering. This module is the record that
10//! closes that: every index created through the API is owned by the key
11//! that created it, carries a `created_at`, and — when the operator
12//! configures a TTL — an `expires_at` after which it is refused.
13//!
14//! The record lives in its own SQLite file in the data dir
15//! (`tenancy.db`), not inside each index's database: `codetopo-store`
16//! owns the per-index schema (graph + audit chain), and ownership is a
17//! property of the *service*, not of one graph.
18//!
19//! An index with **no** owner row is a legacy index — written before
20//! tenancy existed (or placed in the data dir by another tool, e.g. the
21//! CLI). Whether a legacy index is readable is decided by
22//! [`ownerless_readable`]:
23//!
24//! * the operator's flag (`CODETOPO_DENY_OWNERLESS_INDEXES`) wins when
25//!   set — `1`/`true` denies legacy indexes to every key, `0`/`false`
26//!   keeps the original any-key rule;
27//! * unset, the default turns on how many tenants the deployment
28//!   actually has: with **more than one active key** in the store a
29//!   legacy index is denied (an upgraded self-hosted data dir that
30//!   later gained tenants must not leak its pre-tenancy indexes across
31//!   keys — fleet release gate F-5), and with **one** active key it
32//!   stays readable, so the single-operator upgrade that created the
33//!   legacy posture is never stranded by it.
34//!
35//! A denied legacy index is answered exactly like a missing one
36//! (`404 unknown_index`), so the refusal cannot confirm it exists.
37//! The handlers enforce the rule; this module keeps the record and
38//! owns the decision.
39
40use std::path::Path;
41use std::sync::{Mutex, MutexGuard};
42
43use rusqlite::{Connection, OptionalExtension};
44
45use crate::error::ApiError;
46
47/// File name of the tenancy store inside the data dir.
48pub const STORE_FILE_NAME: &str = "tenancy.db";
49
50/// Whether an index with no ownership record ("legacy") may be read
51/// by an authenticated key — the rule the module doc states, kept as
52/// one pure decision so both branches are testable without a server.
53///
54/// `operator_flag` is the operator's ruling
55/// (`CODETOPO_DENY_OWNERLESS_INDEXES`): `Some(true)` denies always,
56/// `Some(false)` allows always. Unset (`None`), the default denies
57/// once the key store holds more than one active key and allows while
58/// it holds at most one — the single-operator self-hosted case the
59/// legacy posture exists for.
60pub 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/// One index's ownership record.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct IndexRecord {
70    /// The index id (the `<id>` in `/v1/graphs/<id>/…`).
71    pub index_id: String,
72    /// The `key_id` that created the index and may query it.
73    pub owner_key_id: String,
74    /// Unix seconds at creation.
75    pub created_at: i64,
76    /// Unix seconds after which the index is refused, if a TTL applies.
77    pub expires_at: Option<i64>,
78}
79
80impl IndexRecord {
81    /// True once `now` has reached the expiry, if this index has one.
82    pub fn is_expired(&self, now: i64) -> bool {
83        self.expires_at.is_some_and(|expires| expires <= now)
84    }
85}
86
87/// The tenancy store: one SQLite connection, serialised behind a mutex.
88///
89/// Lookups are one indexed query per request, trivial next to the
90/// whole-graph load every query already performs. WAL mode plus a busy
91/// timeout keeps the file shareable, matching the key store.
92#[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    /// Opens (creating if needed) the tenancy store at `path`.
106    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    /// An in-memory store, for tests.
125    #[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    /// Records `index_id` as owned by `owner_key_id`, created at
138    /// `created_at` (Unix seconds) and expiring at `expires_at` if given.
139    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    /// The ownership record for `index_id`, or `None` for a legacy index.
157    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    /// How many indexes `owner_key_id` actively holds at `now`:
177    /// records it owns whose expiry has not passed. An expired
178    /// record does not count — it is refused on access and pruned
179    /// on sight, so a key is never charged quota for an index it
180    /// can no longer read (paid-path S3's active-index quota reads
181    /// this number).
182    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    /// Drops the ownership record for `index_id`, if one exists.
197    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    /// Deletes every expired record and returns the expired index ids,
208    /// so the caller can delete the matching `<id>.db` files. Records
209    /// without an expiry never match.
210    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    /// F-5, the default rule's two branches: a legacy index is
256    /// readable while the deployment has a single active key and
257    /// denied the moment a second tenant exists.
258    #[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    /// F-5, the operator flag's two branches: it overrides the
267    /// key-count default in both directions.
268    #[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        // At now=100: "stale" expired at 50, and "boundary" expires
332        // exactly at 100 (the expiry boundary is inclusive, as
333        // `is_expired` pins) — neither counts.
334        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        // Removing a missing record is not an error.
355        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        // A second sweep finds nothing left to do.
373        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}