Skip to main content

erbridge_core/
tenant_store.rs

1//! Where tenants live. [`TenantStore`] is the seam a host can implement to keep
2//! them somewhere other than a file (a database, say); `MemoryStore` and
3//! `FileStore` are the two provided implementations.
4//!
5//! Every change is a read-modify-write of the whole [`TenantSet`], applied
6//! atomically, so concurrent writers (`serve` redeeming a code while an
7//! operator issues another) cannot lose each other's entries.
8
9use std::fs::{File, OpenOptions};
10use std::io::Write as _;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use crate::noise::Psk;
15use anyhow::{Context, Result};
16
17use crate::tenant::{EnrollCode, Tenant, TenantSet};
18
19/// The type of the closure [`TenantStore::replace_with`] applies.
20pub type Edit<'a> = dyn FnMut(&TenantSet) -> Result<TenantSet> + 'a;
21
22pub trait TenantStore: Send + Sync {
23    /// The current contents.
24    fn snapshot(&self) -> Result<TenantSet>;
25
26    /// Atomically replaces the contents with `edit(current)` and returns the
27    /// new contents. If `edit` fails nothing is written.
28    fn replace_with(&self, edit: &mut Edit<'_>) -> Result<TenantSet>;
29
30    fn add_tenant(&self, tenant: Tenant) -> Result<()> {
31        self.replace_with(&mut |s| s.with_tenant(tenant.clone()))
32            .map(drop)
33    }
34
35    fn issue_code(&self, code: EnrollCode) -> Result<()> {
36        self.replace_with(&mut |s| s.with_code(code.clone()))
37            .map(drop)
38    }
39
40    /// Removes a tenant outright; its PSK stops working and its session ends.
41    fn revoke(&self, name: &str) -> Result<()> {
42        self.replace_with(&mut |s| s.without_tenant(name)).map(drop)
43    }
44
45    /// Suspends or resumes a tenant without forgetting it.
46    fn set_disabled(&self, name: &str, disabled: bool) -> Result<()> {
47        self.replace_with(&mut |s| s.with_disabled(name, disabled))
48            .map(drop)
49    }
50
51    /// Burns an enrollment code and creates the tenant it entitles. Exactly one
52    /// of several concurrent redemptions of the same code succeeds.
53    fn redeem(&self, code_psk: &Psk, now_unix: u64, name: String, psk: Psk) -> Result<Tenant> {
54        let mut created = None;
55        self.replace_with(&mut |s| {
56            let (next, tenant) = s.redeem(code_psk, now_unix, name.clone(), psk)?;
57            created = Some(tenant);
58            Ok(next)
59        })?;
60        Ok(created.expect("redeem succeeded without producing a tenant"))
61    }
62}
63
64/// Keeps everything in memory; for tests and hosts that manage persistence
65/// themselves.
66#[derive(Default)]
67pub struct MemoryStore(Mutex<TenantSet>);
68
69impl MemoryStore {
70    pub fn new(initial: TenantSet) -> Self {
71        Self(Mutex::new(initial))
72    }
73}
74
75impl TenantStore for MemoryStore {
76    fn snapshot(&self) -> Result<TenantSet> {
77        Ok(self.0.lock().unwrap().clone())
78    }
79
80    fn replace_with(&self, edit: &mut Edit<'_>) -> Result<TenantSet> {
81        let mut guard = self.0.lock().unwrap();
82        let next = edit(&guard)?;
83        *guard = next.clone();
84        Ok(next)
85    }
86}
87
88/// A TOML file, safe to edit from several processes: writers take an exclusive
89/// lock on a sidecar `<file>.lock`, and replace the file by atomic rename so a
90/// reader never sees a half-written one.
91pub struct FileStore {
92    path: PathBuf,
93}
94
95impl FileStore {
96    pub fn new(path: impl Into<PathBuf>) -> Self {
97        Self { path: path.into() }
98    }
99
100    pub fn path(&self) -> &Path {
101        &self.path
102    }
103
104    fn sidecar(&self, suffix: &str) -> PathBuf {
105        let mut name = self.path.as_os_str().to_owned();
106        name.push(suffix);
107        PathBuf::from(name)
108    }
109}
110
111impl TenantStore for FileStore {
112    fn snapshot(&self) -> Result<TenantSet> {
113        TenantSet::load(&self.path)
114    }
115
116    fn replace_with(&self, edit: &mut Edit<'_>) -> Result<TenantSet> {
117        let lock = OpenOptions::new()
118            .create(true)
119            .truncate(false)
120            .write(true)
121            .open(self.sidecar(".lock"))
122            .with_context(|| format!("opening lock file for {}", self.path.display()))?;
123        lock.lock().context("locking the tenant file")?;
124
125        let current = TenantSet::load(&self.path)?;
126        let next = edit(&current)?;
127        let text = next.to_toml()?;
128
129        // Unique per writer, so a crashed writer's leftover cannot be mistaken
130        // for a live one's.
131        let tmp = self.sidecar(&format!(".tmp-{}", std::process::id()));
132        let written = write_private(&tmp, text.as_bytes())
133            .with_context(|| format!("writing {}", tmp.display()))
134            .and_then(|()| {
135                std::fs::rename(&tmp, &self.path)
136                    .with_context(|| format!("replacing {}", self.path.display()))
137            });
138        if written.is_err() {
139            let _ = std::fs::remove_file(&tmp);
140        }
141        written?;
142        // The lock is released when `lock` drops.
143        Ok(next)
144    }
145}
146
147/// Writes `bytes` to `path` readable by its owner only: the file holds PSKs.
148fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
149    // A leftover from a crashed writer may have looser permissions, and the
150    // mode below only applies to a file this call creates.
151    match std::fs::remove_file(path) {
152        Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
153        _ => {}
154    }
155    let mut options = OpenOptions::new();
156    options.create_new(true).write(true);
157    #[cfg(unix)]
158    {
159        use std::os::unix::fs::OpenOptionsExt;
160        options.mode(0o600);
161    }
162    let mut file: File = options.open(path)?;
163    file.write_all(bytes)?;
164    file.sync_all()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::noise::derive_psk;
171    use std::sync::Arc;
172
173    fn tenant(name: &str, token: &str, sni: &str) -> Tenant {
174        Tenant {
175            name: name.into(),
176            psk: derive_psk(token),
177            sni: sni.into(),
178            max_conns: 8,
179            disabled: false,
180        }
181    }
182
183    fn code(token: &str, sni: &str, expires_at: Option<u64>) -> EnrollCode {
184        EnrollCode {
185            psk: derive_psk(token),
186            sni: sni.into(),
187            max_conns: 5,
188            expires_at,
189        }
190    }
191
192    /// The behaviour every implementation must share.
193    fn conformance(store: &dyn TenantStore) {
194        assert_eq!(store.snapshot().unwrap(), TenantSet::default());
195
196        store.add_tenant(tenant("a", "ta", "a.x")).unwrap();
197        store.add_tenant(tenant("b", "tb", "b.x")).unwrap();
198        assert!(
199            store.add_tenant(tenant("a", "other", "z.x")).is_err(),
200            "duplicate name"
201        );
202        assert_eq!(store.snapshot().unwrap().tenants().len(), 2);
203
204        store.set_disabled("a", true).unwrap();
205        assert!(store.snapshot().unwrap().tenant("a").unwrap().disabled);
206        assert!(store.set_disabled("nope", true).is_err());
207
208        store.revoke("a").unwrap();
209        assert!(store.snapshot().unwrap().tenant("a").is_none());
210        assert!(store.snapshot().unwrap().tenant("b").is_some());
211        assert!(store.revoke("a").is_err());
212
213        // Redeeming creates a tenant from the code's host and limit, and burns it.
214        store.issue_code(code("c1", "c.x", None)).unwrap();
215        let made = store
216            .redeem(&derive_psk("c1"), 0, "t-new".into(), derive_psk("fresh"))
217            .unwrap();
218        assert_eq!((made.sni.as_str(), made.max_conns), ("c.x", 5));
219        let snap = store.snapshot().unwrap();
220        assert!(snap.codes().is_empty());
221        assert_eq!(snap.tenant("t-new").unwrap().psk, derive_psk("fresh"));
222        assert!(
223            store
224                .redeem(&derive_psk("c1"), 0, "t-again".into(), derive_psk("x"))
225                .is_err(),
226            "a code works once"
227        );
228
229        // Expired codes cannot be redeemed, and the failure changes nothing.
230        store.issue_code(code("old", "o.x", Some(100))).unwrap();
231        let before = store.snapshot().unwrap();
232        assert!(
233            store
234                .redeem(&derive_psk("old"), 100, "t-old".into(), derive_psk("y"))
235                .is_err()
236        );
237        assert_eq!(store.snapshot().unwrap(), before);
238    }
239
240    fn concurrent_writers_lose_nothing(store: Arc<dyn TenantStore>) {
241        let threads: Vec<_> = (0..8)
242            .map(|t| {
243                let store = store.clone();
244                std::thread::spawn(move || {
245                    for i in 0..10 {
246                        let n = t * 10 + i;
247                        store
248                            .issue_code(code(&format!("tok-{n}"), &format!("h{n}.x"), None))
249                            .unwrap();
250                    }
251                })
252            })
253            .collect();
254        for t in threads {
255            t.join().unwrap();
256        }
257        assert_eq!(store.snapshot().unwrap().codes().len(), 80);
258    }
259
260    #[test]
261    fn memory_store_conforms() {
262        conformance(&MemoryStore::default());
263        concurrent_writers_lose_nothing(Arc::new(MemoryStore::default()));
264    }
265
266    #[test]
267    fn file_store_conforms() {
268        let dir = tempfile::tempdir().unwrap();
269        conformance(&FileStore::new(dir.path().join("tenants.toml")));
270        let dir = tempfile::tempdir().unwrap();
271        concurrent_writers_lose_nothing(Arc::new(FileStore::new(dir.path().join("t.toml"))));
272    }
273
274    #[test]
275    fn two_file_stores_on_one_file_do_not_lose_each_others_writes() {
276        // Stands in for `serve` and `serve-admin` writing from two processes.
277        let dir = tempfile::tempdir().unwrap();
278        let path = dir.path().join("t.toml");
279        let (a, b) = (FileStore::new(&path), FileStore::new(&path));
280        let ta = std::thread::spawn(move || {
281            for i in 0..20 {
282                a.issue_code(code(&format!("a{i}"), &format!("a{i}.x"), None))
283                    .unwrap();
284            }
285        });
286        let tb = std::thread::spawn(move || {
287            for i in 0..20 {
288                b.issue_code(code(&format!("b{i}"), &format!("b{i}.x"), None))
289                    .unwrap();
290            }
291        });
292        ta.join().unwrap();
293        tb.join().unwrap();
294        assert_eq!(FileStore::new(&path).snapshot().unwrap().codes().len(), 40);
295    }
296
297    #[test]
298    fn a_missing_file_is_an_empty_set_and_a_corrupt_one_is_an_error() {
299        let dir = tempfile::tempdir().unwrap();
300        let path = dir.path().join("t.toml");
301        let store = FileStore::new(&path);
302        assert_eq!(store.snapshot().unwrap(), TenantSet::default());
303
304        std::fs::write(&path, "this is [not valid").unwrap();
305        assert!(store.snapshot().is_err());
306        assert!(store.add_tenant(tenant("a", "t", "a.x")).is_err());
307        assert_eq!(
308            std::fs::read_to_string(&path).unwrap(),
309            "this is [not valid",
310            "a failed edit must not overwrite the file"
311        );
312    }
313
314    #[cfg(unix)]
315    #[test]
316    fn the_file_is_readable_by_its_owner_only() {
317        use std::os::unix::fs::PermissionsExt;
318        let dir = tempfile::tempdir().unwrap();
319        let path = dir.path().join("t.toml");
320        FileStore::new(&path)
321            .add_tenant(tenant("a", "t", "a.x"))
322            .unwrap();
323        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
324        assert_eq!(mode & 0o777, 0o600);
325    }
326}