Skip to main content

skeg_server_tenant/
lib.rs

1//! Multi-tenant wrapper for `skeg-server`.
2//!
3//! Implements [`skeg_server::TenantBackend`] on top of the `skeg-tenant`
4//! primitives (auth store, tenant ids, argon2 password hashing), plus a
5//! persisted per-tenant quota store an admin writes via `SKEG.QUOTA.SET`.
6
7#![deny(unsafe_code)]
8
9mod limits;
10
11use std::error::Error;
12use std::path::Path;
13use std::sync::Arc;
14
15use parking_lot::RwLock;
16use skeg_server::{AnonymousPolicy, QuotaAdminError, TenantBackend, TenantId, TenantLimits};
17use skeg_tenant::auth::{Argon2Params, PasswordHash, hash_password_with};
18use skeg_tenant::{AuthStore, TenantId as TenantTenantId};
19
20use crate::limits::LimitsStore;
21
22/// `TenantBackend` implementation backed by an on-disk `auth.kdb` (identity)
23/// and a sidecar quota store (per-tenant limits).
24pub struct AuthStoreBackend {
25    auth: Arc<RwLock<AuthStore>>,
26    limits: RwLock<LimitsStore>,
27    decoy: PasswordHash,
28    strict: bool,
29    /// Tenant allowed to run admin commands, if any (`--admin-tenant`).
30    admin: Option<TenantTenantId>,
31}
32
33impl AuthStoreBackend {
34    /// Open `auth.kdb` at `path` plus its `<path>.quotas` sidecar.
35    ///
36    /// `strict = true` rejects anonymous `HELLO 3`. `admin_tenant` names the
37    /// tenant permitted to run `SKEG.QUOTA.SET/GET`; `None` means no admin.
38    ///
39    /// # Errors
40    ///
41    /// Returns the underlying store / hashing error.
42    pub fn open(
43        path: impl AsRef<Path>,
44        strict: bool,
45        admin_tenant: Option<&str>,
46    ) -> Result<Arc<Self>, Box<dyn Error>> {
47        let path = path.as_ref();
48        let store = AuthStore::open(path)?;
49        let quotas_path: std::path::PathBuf = format!("{}.quotas", path.to_string_lossy()).into();
50        let limits = LimitsStore::open(quotas_path)?;
51        // Precomputed decoy hash used when verifying an unknown user, so the
52        // timing of "wrong password" and "unknown user" is the same.
53        let decoy = hash_password_with(b"skeg-decoy", Argon2Params::default())?;
54        Ok(Arc::new(Self {
55            auth: Arc::new(RwLock::new(store)),
56            limits: RwLock::new(limits),
57            decoy,
58            strict,
59            admin: admin_tenant.map(TenantTenantId::from_name),
60        }))
61    }
62}
63
64fn tid_to_engine(t: TenantTenantId) -> TenantId {
65    TenantId::from_bytes(*t.as_bytes())
66}
67
68fn tid_from_engine(t: TenantId) -> TenantTenantId {
69    TenantTenantId::from_bytes(*t.as_bytes())
70}
71
72impl TenantBackend for AuthStoreBackend {
73    fn verify_login(&self, user: &str, password: &[u8]) -> Option<TenantId> {
74        self.auth
75            .read()
76            .verify_login(user, password, &self.decoy)
77            .ok()
78            .map(tid_to_engine)
79    }
80
81    fn has_tenant(&self, id: TenantId) -> bool {
82        self.auth.read().has_tenant(tid_from_engine(id))
83    }
84
85    fn anonymous_policy(&self) -> AnonymousPolicy {
86        if self.strict {
87            AnonymousPolicy::Strict
88        } else {
89            AnonymousPolicy::Lenient
90        }
91    }
92
93    fn limits(&self, id: TenantId) -> TenantLimits {
94        let (max_vectors, max_disk_bytes) = self.limits.read().get(*tid_from_engine(id).as_bytes());
95        TenantLimits {
96            max_vectors,
97            max_disk_bytes,
98        }
99    }
100
101    fn is_admin(&self, id: TenantId) -> bool {
102        !id.is_zero() && self.admin == Some(tid_from_engine(id))
103    }
104
105    fn resolve_tenant(&self, name: &str) -> Option<TenantId> {
106        let t = TenantTenantId::from_name(name);
107        self.auth.read().has_tenant(t).then(|| tid_to_engine(t))
108    }
109
110    fn set_limits(&self, id: TenantId, limits: TenantLimits) -> Result<(), QuotaAdminError> {
111        self.limits
112            .write()
113            .set(
114                *tid_from_engine(id).as_bytes(),
115                (limits.max_vectors, limits.max_disk_bytes),
116            )
117            .map_err(|_| QuotaAdminError::Unsupported)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use tempfile::TempDir;
125
126    /// Write an `auth.kdb` binding each `(user, tenant_name)`.
127    fn write_auth(dir: &Path, users: &[(&str, &str)]) -> std::path::PathBuf {
128        let path = dir.join("auth.kdb");
129        let mut store = AuthStore::open(&path).unwrap();
130        let hash = hash_password_with(b"pw", Argon2Params::default()).unwrap();
131        for (user, tenant) in users {
132            // upsert returns the prior record (None for a new user); ignore it.
133            store.upsert(*user, TenantTenantId::from_name(tenant), hash.clone());
134        }
135        store.save().unwrap();
136        path
137    }
138
139    #[test]
140    fn admin_sets_and_persists_tenant_limits() {
141        let dir = TempDir::new().unwrap();
142        let path = write_auth(dir.path(), &[("admin", "admin"), ("u", "acme")]);
143        let be = AuthStoreBackend::open(&path, false, Some("admin")).unwrap();
144
145        let admin_id = tid_to_engine(TenantTenantId::from_name("admin"));
146        let acme_id = tid_to_engine(TenantTenantId::from_name("acme"));
147
148        // admin gating
149        assert!(be.is_admin(admin_id));
150        assert!(!be.is_admin(acme_id));
151        assert!(!be.is_admin(TenantId::ZERO));
152
153        // name resolution
154        assert_eq!(be.resolve_tenant("acme"), Some(acme_id));
155        assert_eq!(be.resolve_tenant("nobody"), None);
156
157        // default unlimited, then a set the engine can read back
158        assert_eq!(be.limits(acme_id), TenantLimits::default());
159        be.set_limits(
160            acme_id,
161            TenantLimits {
162                max_vectors: Some(1000),
163                max_disk_bytes: Some(1 << 20),
164            },
165        )
166        .unwrap();
167        assert_eq!(be.limits(acme_id).max_vectors, Some(1000));
168        assert_eq!(be.limits(acme_id).max_disk_bytes, Some(1 << 20));
169
170        // persisted: a fresh backend over the same files sees it
171        drop(be);
172        let be2 = AuthStoreBackend::open(&path, false, Some("admin")).unwrap();
173        assert_eq!(be2.limits(acme_id).max_vectors, Some(1000));
174    }
175
176    #[test]
177    fn no_admin_configured_means_no_admin() {
178        let dir = TempDir::new().unwrap();
179        let path = write_auth(dir.path(), &[("u", "acme")]);
180        let be = AuthStoreBackend::open(&path, false, None).unwrap();
181        let acme_id = tid_to_engine(TenantTenantId::from_name("acme"));
182        assert!(!be.is_admin(acme_id));
183        assert!(be.set_limits(acme_id, TenantLimits::default()).is_ok());
184    }
185}