Skip to main content

m4a_product_example/
store.rs

1//! SQLite implementation of the kit's [`UserStore`]: the example product's own database.
2//! This is part of what a real product throws away (it has its own user database). The
3//! storage engine is `tesserax-store` (SQLCipher, one writer, WAL): every trait method is one
4//! short closure on the writer connection, so no lock outlives a call and no async request
5//! holds one across a call to the edge. The trait is synchronous, so callers on the async
6//! runtime reach it through `spawn_blocking`; the engine refuses to block a runtime thread.
7
8use m4a_product_kit::model::{StoreError, StoreResult, User, UserStore};
9use tesserax_store::rusqlite::{self, params, Connection, OptionalExtension};
10use tesserax_store::{Db, DbConfig};
11
12pub struct SqliteStore {
13    db: Db,
14}
15
16/// Server role, ordered: `User < Moderator < Admin`.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
18pub enum Role {
19    User,
20    Moderator,
21    Admin,
22}
23
24impl Role {
25    pub fn parse(s: &str) -> Option<Self> {
26        match s {
27            "user" => Some(Self::User),
28            "moderator" => Some(Self::Moderator),
29            "admin" => Some(Self::Admin),
30            _ => None,
31        }
32    }
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::User => "user",
36            Self::Moderator => "moderator",
37            Self::Admin => "admin",
38        }
39    }
40}
41
42impl SqliteStore {
43    /// `(kind, role)` of a user; defaults `("agent", User)`.
44    pub fn roles(&self, user_id: i64) -> StoreResult<(String, Role)> {
45        self.with(|c| {
46            let row: Option<(String, String)> = c
47                .query_row("SELECT kind, role FROM user_roles WHERE user_id = ?1", params![user_id], |r| Ok((r.get(0)?, r.get(1)?)))
48                .map(Some)
49                .or_else(|e| if matches!(e, rusqlite::Error::QueryReturnedNoRows) { Ok(None) } else { Err(e) })
50                .map_err(be)?;
51            Ok(row.map(|(k, r)| (k, Role::parse(&r).unwrap_or(Role::User))).unwrap_or_else(|| ("agent".into(), Role::User)))
52        })
53    }
54    /// Sets either axis (or both); the other keeps its value.
55    pub fn set_roles(&self, user_id: i64, kind: Option<&str>, role: Option<Role>) -> StoreResult<(String, Role)> {
56        let (k0, r0) = self.roles(user_id)?;
57        let (k, r) = (kind.map(str::to_owned).unwrap_or(k0), role.unwrap_or(r0));
58        self.with(|c| {
59            c.execute("INSERT INTO user_roles (user_id, kind, role) VALUES (?1, ?2, ?3) ON CONFLICT(user_id) DO UPDATE SET kind = ?2, role = ?3", params![user_id, k, r.as_str()]).map_err(be)?;
60            Ok(())
61        })?;
62        Ok((k, r))
63    }
64}
65
66fn be(e: rusqlite::Error) -> StoreError {
67    match &e {
68        rusqlite::Error::SqliteFailure(f, _) if f.code == rusqlite::ErrorCode::ConstraintViolation => StoreError::NickTaken,
69        _ => StoreError::Backend(e.to_string()),
70    }
71}
72
73const SCHEMA: &str = r#"
74-- Account kind (human|agent) and server role (admin|moderator|user); independent axes. A missing
75-- row means kind=agent, role=user. Granted only with the admin token.
76CREATE TABLE IF NOT EXISTS user_roles (
77    user_id INTEGER PRIMARY KEY,
78    kind TEXT NOT NULL DEFAULT 'agent' CHECK (kind IN ('human','agent')),
79    role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','moderator','user'))
80);
81CREATE TABLE IF NOT EXISTS users (
82    id INTEGER PRIMARY KEY AUTOINCREMENT,
83    nick TEXT NOT NULL,
84    nick_ci TEXT NOT NULL UNIQUE,
85    tier TEXT NOT NULL,
86    secret_hash TEXT,
87    nick_changes INTEGER NOT NULL DEFAULT 0,
88    nick_changed_ms INTEGER NOT NULL DEFAULT 0,
89    created_ms INTEGER NOT NULL
90);
91CREATE TABLE IF NOT EXISTS credentials (
92    cred_ref TEXT PRIMARY KEY,
93    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
94    token_hash TEXT NOT NULL UNIQUE,
95    created_ms INTEGER NOT NULL,
96    access_expires_ms INTEGER,
97    refresh_hash TEXT,
98    refresh_expires_ms INTEGER
99);
100CREATE TABLE IF NOT EXISTS user_keys (
101    key_id TEXT PRIMARY KEY,
102    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
103    public_key TEXT NOT NULL,
104    label TEXT NOT NULL,
105    created_ms INTEGER NOT NULL
106);
107CREATE TABLE IF NOT EXISTS invites (
108    code_hash TEXT PRIMARY KEY,
109    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
110    expires_ms INTEGER NOT NULL
111);
112CREATE TABLE IF NOT EXISTS door_links (
113    source TEXT NOT NULL,
114    subject TEXT NOT NULL,
115    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
116    PRIMARY KEY (source, subject)
117);
118"#;
119
120const UCOLS: &str = "id, nick, tier, nick_changes, nick_changed_ms";
121
122fn user(r: &rusqlite::Row<'_>) -> rusqlite::Result<User> {
123    Ok(User { id: r.get(0)?, nick: r.get(1)?, tier: r.get(2)?, nick_changes: r.get::<_, i64>(3)? as u32, nick_changed_ms: r.get(4)? })
124}
125
126impl SqliteStore {
127    /// The product database in an opened store (shared with the event queue: one writer per file).
128    pub fn new(db: Db) -> Result<Self, String> {
129        db.blocking(|c| {
130            c.execute_batch(SCHEMA)?;
131            // Databases made before expiring sessions: add the columns.
132            for (col, ty) in [("access_expires_ms", "INTEGER"), ("refresh_hash", "TEXT"), ("refresh_expires_ms", "INTEGER")] {
133                let have: bool = c.query_row("SELECT COUNT(*) FROM pragma_table_info('credentials') WHERE name = ?1", params![col], |r| r.get::<_, i64>(0)).map(|n| n > 0)?;
134                if !have {
135                    c.execute_batch(&format!("ALTER TABLE credentials ADD COLUMN {col} {ty}"))?;
136                }
137            }
138            // Invites made before nick requests: every one of them reserved its nick.
139            let have: bool = c.query_row("SELECT COUNT(*) FROM pragma_table_info('invites') WHERE name = 'reserved'", [], |r| r.get::<_, i64>(0)).map(|n| n > 0)?;
140            if !have {
141                c.execute_batch("ALTER TABLE invites ADD COLUMN reserved INTEGER NOT NULL DEFAULT 1")?;
142            }
143            Ok(())
144        })
145        .map_err(|e| e.to_string())?;
146        Ok(Self { db })
147    }
148    /// Opens the store described by `cfg` (see `m4a_product_kit::dbkey`).
149    pub fn open(cfg: &DbConfig) -> Result<Self, String> {
150        Self::new(Db::open(cfg).map_err(|e| e.to_string())?)
151    }
152    /// Plaintext in-memory database, for tests only.
153    pub fn memory() -> Result<Self, String> {
154        Self::open(&DbConfig::in_memory())
155    }
156    /// The underlying store, to share its single writer with the event queue.
157    pub fn db(&self) -> Db {
158        self.db.clone()
159    }
160    fn with<T>(&self, f: impl FnOnce(&mut Connection) -> StoreResult<T>) -> StoreResult<T> {
161        match self.db.write_blocking(|c| Ok(f(c))) {
162            Ok(r) => r,
163            Err(e) => Err(be(e)),
164        }
165    }
166}
167
168impl UserStore for SqliteStore {
169    fn user_by_nick(&self, nick: &str) -> StoreResult<Option<User>> {
170        self.with(|c| {
171        c.query_row(&format!("SELECT {UCOLS} FROM users WHERE nick_ci = ?1"), params![nick.to_ascii_lowercase()], user).optional().map_err(be)
172        })
173    }
174    fn insert_user(&self, nick: &str, tier: &str, secret_hash: Option<&str>, now_ms: i64) -> StoreResult<User> {
175        self.with(|c| {
176        c.execute("INSERT INTO users (nick, nick_ci, tier, secret_hash, created_ms) VALUES (?1, ?2, ?3, ?4, ?5)", params![nick, nick.to_ascii_lowercase(), tier, secret_hash, now_ms]).map_err(be)?;
177        let id = c.last_insert_rowid();
178        c.query_row(&format!("SELECT {UCOLS} FROM users WHERE id = ?1"), params![id], user).map_err(be)
179        })
180    }
181    fn secret_hash(&self, user_id: i64) -> StoreResult<Option<String>> {
182        self.with(|c| {
183        Ok(c.query_row("SELECT secret_hash FROM users WHERE id = ?1", params![user_id], |r| r.get::<_, Option<String>>(0)).optional().map_err(be)?.flatten())
184        })
185    }
186    fn update_nick(&self, user_id: i64, new_nick: &str, now_ms: i64) -> StoreResult<User> {
187        self.with(|c| {
188        let n = c.execute("UPDATE users SET nick = ?2, nick_ci = ?3, nick_changes = nick_changes + 1, nick_changed_ms = ?4 WHERE id = ?1", params![user_id, new_nick, new_nick.to_ascii_lowercase(), now_ms]).map_err(be)?;
189        if n == 0 {
190            return Err(StoreError::NotFound);
191        }
192        c.query_row(&format!("SELECT {UCOLS} FROM users WHERE id = ?1"), params![user_id], user).map_err(be)
193        })
194    }
195    fn set_tier(&self, user_id: i64, tier: &str) -> StoreResult<()> {
196        self.with(|c| {
197        match c.execute("UPDATE users SET tier = ?2 WHERE id = ?1", params![user_id, tier]).map_err(be)? {
198            0 => Err(StoreError::NotFound),
199            _ => Ok(()),
200        }
201        })
202    }
203    fn delete_user(&self, user_id: i64) -> StoreResult<()> {
204        self.with(|c| {
205        c.execute("DELETE FROM user_roles WHERE user_id = ?1", params![user_id]).map_err(be)?;
206        c.execute("DELETE FROM users WHERE id = ?1", params![user_id]).map_err(be).map(|_| ())
207        })
208    }
209    fn add_credential(&self, user_id: i64, cred_ref: &str, token_hash: &str, now_ms: i64) -> StoreResult<()> {
210        self.with(|c| {
211        c.execute("INSERT INTO credentials (cred_ref, user_id, token_hash, created_ms) VALUES (?1, ?2, ?3, ?4)", params![cred_ref, user_id, token_hash, now_ms]).map_err(be).map(|_| ())
212        })
213    }
214    fn user_by_token_hash(&self, token_hash: &str) -> StoreResult<Option<(User, String)>> {
215        self.with(|c| {
216        c
217            .query_row(
218                "SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms, c.cred_ref FROM credentials c JOIN users u ON u.id = c.user_id WHERE c.token_hash = ?1 AND (c.access_expires_ms IS NULL OR c.access_expires_ms > CAST(strftime('%s','now') AS INTEGER) * 1000)",
219                params![token_hash],
220                |r| Ok((user(r)?, r.get::<_, String>(5)?)),
221            )
222            .optional()
223            .map_err(be)
224        })
225    }
226    fn delete_credential(&self, cred_ref: &str) -> StoreResult<Option<i64>> {
227        self.with(|c| {
228        let owner: Option<i64> = c.query_row("SELECT user_id FROM credentials WHERE cred_ref = ?1", params![cred_ref], |r| r.get(0)).optional().map_err(be)?;
229        if owner.is_some() {
230            c.execute("DELETE FROM credentials WHERE cred_ref = ?1", params![cred_ref]).map_err(be)?;
231        }
232        Ok(owner)
233        })
234    }
235    fn credentials_of(&self, user_id: i64) -> StoreResult<Vec<String>> {
236        self.with(|c| {
237        let mut st = c.prepare("SELECT cred_ref FROM credentials WHERE user_id = ?1").map_err(be)?;
238        let rows = st.query_map(params![user_id], |r| r.get(0)).map_err(be)?;
239        rows.collect::<Result<_, _>>().map_err(be)
240        })
241    }
242    fn live_credentials(&self) -> StoreResult<Vec<(String, Vec<String>)>> {
243        self.with(|c| {
244        let mut out: Vec<(String, Vec<String>)> = Vec::new();
245        let mut users = c.prepare("SELECT id, nick FROM users ORDER BY id").map_err(be)?;
246        let rows: Vec<(i64, String)> = users.query_map([], |r| Ok((r.get(0)?, r.get(1)?))).map_err(be)?.collect::<Result<_, _>>().map_err(be)?;
247        let mut creds = c.prepare("SELECT cred_ref FROM credentials WHERE user_id = ?1").map_err(be)?;
248        for (id, nick) in rows {
249            let list: Vec<String> = creds.query_map(params![id], |r| r.get(0)).map_err(be)?.collect::<Result<_, _>>().map_err(be)?;
250            out.push((nick, list));
251        }
252        Ok(out)
253        })
254    }
255    fn user_by_door(&self, source: &str, subject: &str) -> StoreResult<Option<User>> {
256        self.with(|c| {
257        c
258            .query_row("SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms FROM door_links d JOIN users u ON u.id = d.user_id WHERE d.source = ?1 AND d.subject = ?2", params![source, subject], user)
259            .optional()
260            .map_err(be)
261        })
262    }
263    fn set_secret_hash(&self, user_id: i64, hash: &str) -> StoreResult<()> {
264        self.with(|c| match c.execute("UPDATE users SET secret_hash = ?2 WHERE id = ?1", params![user_id, hash]).map_err(be)? {
265            0 => Err(StoreError::NotFound),
266            _ => Ok(()),
267        })
268    }
269    fn supports_refresh(&self) -> bool {
270        true
271    }
272    fn set_refresh(&self, cred_ref: &str, refresh_hash: &str, access_expires_ms: i64, refresh_expires_ms: i64) -> StoreResult<()> {
273        self.with(|c| {
274            c.execute("UPDATE credentials SET refresh_hash = ?2, access_expires_ms = ?3, refresh_expires_ms = ?4 WHERE cred_ref = ?1", params![cred_ref, refresh_hash, access_expires_ms, refresh_expires_ms]).map_err(be).map(|_| ())
275        })
276    }
277    fn take_refresh(&self, refresh_hash: &str, now_ms: i64) -> StoreResult<Option<String>> {
278        self.with(|c| {
279            let found: Option<String> = c.query_row("SELECT cred_ref FROM credentials WHERE refresh_hash = ?1 AND refresh_expires_ms > ?2", params![refresh_hash, now_ms], |r| r.get(0)).optional().map_err(be)?;
280            if let Some(cr) = &found {
281                c.execute("UPDATE credentials SET refresh_hash = NULL WHERE cred_ref = ?1", params![cr]).map_err(be)?;
282            }
283            Ok(found)
284        })
285    }
286    fn replace_token(&self, cred_ref: &str, token_hash: &str, access_expires_ms: i64) -> StoreResult<()> {
287        self.with(|c| {
288            c.execute("UPDATE credentials SET token_hash = ?2, access_expires_ms = ?3 WHERE cred_ref = ?1", params![cred_ref, token_hash, access_expires_ms]).map_err(be).map(|_| ())
289        })
290    }
291    fn supports_keys(&self) -> bool {
292        true
293    }
294    fn create_invite(&self, code_hash: &str, user_id: i64, expires_ms: i64) -> StoreResult<()> {
295        self.with(|c| c.execute("INSERT INTO invites (code_hash, user_id, expires_ms) VALUES (?1, ?2, ?3)", params![code_hash, user_id, expires_ms]).map_err(be).map(|_| ()))
296    }
297    fn create_invite_with(&self, code_hash: &str, user_id: i64, expires_ms: i64, reserved: bool) -> StoreResult<()> {
298        self.with(|c| {
299            c.execute("INSERT OR REPLACE INTO invites (code_hash, user_id, expires_ms, reserved) VALUES (?1, ?2, ?3, ?4)", params![code_hash, user_id, expires_ms, reserved as i64]).map_err(be).map(|_| ())
300        })
301    }
302    fn peek_invite(&self, code_hash: &str, now_ms: i64) -> StoreResult<Option<(User, bool)>> {
303        self.with(|c| {
304            c.query_row(
305                "SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms, i.reserved FROM invites i JOIN users u ON u.id = i.user_id WHERE i.code_hash = ?1 AND i.expires_ms > ?2",
306                params![code_hash, now_ms],
307                |r| Ok((user(r)?, r.get::<_, i64>(5)? != 0)),
308            )
309            .optional()
310            .map_err(be)
311        })
312    }
313    fn take_invite(&self, code_hash: &str, now_ms: i64) -> StoreResult<Option<User>> {
314        Ok(self.take_invite_with(code_hash, now_ms)?.map(|(u, _)| u))
315    }
316    fn take_invite_with(&self, code_hash: &str, now_ms: i64) -> StoreResult<Option<(User, bool)>> {
317        self.with(|c| {
318            let found = c
319                .query_row(
320                    "SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms, i.reserved FROM invites i JOIN users u ON u.id = i.user_id WHERE i.code_hash = ?1 AND i.expires_ms > ?2",
321                    params![code_hash, now_ms],
322                    |r| Ok((user(r)?, r.get::<_, i64>(5)? != 0)),
323                )
324                .optional()
325                .map_err(be)?;
326            // Spent or expired, it goes either way.
327            c.execute("DELETE FROM invites WHERE code_hash = ?1", params![code_hash]).map_err(be)?;
328            Ok(found)
329        })
330    }
331    fn add_key(&self, user_id: i64, key_id: &str, public_key: &str, label: &str, now_ms: i64) -> StoreResult<()> {
332        self.with(|c| c.execute("INSERT INTO user_keys (key_id, user_id, public_key, label, created_ms) VALUES (?1, ?2, ?3, ?4, ?5)", params![key_id, user_id, public_key, label, now_ms]).map_err(be).map(|_| ()))
333    }
334    fn user_by_key(&self, key_id: &str) -> StoreResult<Option<(User, String)>> {
335        self.with(|c| {
336            c.query_row(
337                "SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms, k.public_key FROM user_keys k JOIN users u ON u.id = k.user_id WHERE k.key_id = ?1",
338                params![key_id],
339                |r| Ok((user(r)?, r.get::<_, String>(5)?)),
340            )
341            .optional()
342            .map_err(be)
343        })
344    }
345    fn delete_key(&self, key_id: &str) -> StoreResult<bool> {
346        self.with(|c| c.execute("DELETE FROM user_keys WHERE key_id = ?1", params![key_id]).map_err(be).map(|n| n > 0))
347    }
348    fn link_door(&self, user_id: i64, source: &str, subject: &str) -> StoreResult<()> {
349        self.with(|c| {
350        c.execute("INSERT INTO door_links (source, subject, user_id) VALUES (?1, ?2, ?3)", params![source, subject, user_id]).map_err(be).map(|_| ())
351        })
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    const KEY: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
360    const OTHER: &str = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100";
361
362    #[test]
363    fn the_product_database_is_encrypted_and_keeps_its_users() {
364        let path = format!("/tmp/m4a-example-store-{}.db", std::process::id());
365        let open = |k: &str| SqliteStore::open(&m4a_product_kit::dbkey::config(&path, k).unwrap());
366        {
367            let s = open(KEY).unwrap();
368            s.insert_user("zoe_nick", "free", Some("hash"), 1).unwrap();
369        }
370        assert_eq!(open(KEY).unwrap().user_by_nick("zoe_nick").unwrap().unwrap().nick, "zoe_nick");
371        assert!(open(OTHER).is_err());
372        let raw = std::fs::read(&path).unwrap();
373        assert!(!raw.starts_with(b"SQLite format 3") && !raw.windows(8).any(|w| w == b"zoe_nick"));
374        for ext in ["", "-wal", "-shm"] {
375            let _ = std::fs::remove_file(format!("{path}{ext}"));
376        }
377    }
378}