Skip to main content

assay_auth/store/
postgres.rs

1//! Postgres implementations of [`UserStore`] and [`SessionStore`].
2//!
3//! Tables live in the `auth` schema (created/migrated by
4//! [`crate::schema::migrate_postgres`]). Queries are schema-qualified
5//! (`auth.users`, …) so the connection's `search_path` is irrelevant —
6//! matches the rest of the codebase's "explicit > implicit" stance.
7
8use std::sync::Arc;
9
10use anyhow::{Context, Result};
11use sqlx::{PgPool, Row};
12
13use super::types::{PasskeyCred, Session, User};
14use super::{SessionStore, UserStore};
15
16/// User store backed by a shared `PgPool`. Cheap to clone (the
17/// underlying pool is `Arc`d already).
18#[derive(Clone)]
19pub struct PostgresUserStore {
20    pool: PgPool,
21}
22
23impl PostgresUserStore {
24    pub fn new(pool: PgPool) -> Self {
25        Self { pool }
26    }
27
28    /// Wrap into an `Arc<dyn UserStore>` for [`crate::ctx::AuthCtx`].
29    pub fn into_dyn(self) -> Arc<dyn UserStore> {
30        Arc::new(self)
31    }
32}
33
34#[async_trait::async_trait]
35impl UserStore for PostgresUserStore {
36    async fn create_user(&self, user: &User) -> Result<()> {
37        sqlx::query(
38            "INSERT INTO auth.users
39                 (id, email, email_verified, display_name, password_hash, created_at)
40             VALUES ($1, $2, $3, $4, NULL, $5)",
41        )
42        .bind(&user.id)
43        .bind(&user.email)
44        .bind(user.email_verified)
45        .bind(&user.display_name)
46        .bind(user.created_at)
47        .execute(&self.pool)
48        .await
49        .context("auth.users insert")?;
50        Ok(())
51    }
52
53    async fn get_user_by_id(&self, id: &str) -> Result<Option<User>> {
54        let row = sqlx::query(
55            "SELECT id, email, email_verified, display_name, created_at
56             FROM auth.users WHERE id = $1",
57        )
58        .bind(id)
59        .fetch_optional(&self.pool)
60        .await
61        .context("auth.users select by id")?;
62        Ok(row.map(map_user_row_pg))
63    }
64
65    async fn get_user_by_email(&self, email: &str) -> Result<Option<User>> {
66        let row = sqlx::query(
67            "SELECT id, email, email_verified, display_name, created_at
68             FROM auth.users WHERE email = $1",
69        )
70        .bind(email)
71        .fetch_optional(&self.pool)
72        .await
73        .context("auth.users select by email")?;
74        Ok(row.map(map_user_row_pg))
75    }
76
77    async fn update_user(&self, user: &User) -> Result<()> {
78        sqlx::query(
79            "UPDATE auth.users
80             SET email = $2,
81                 email_verified = $3,
82                 display_name = $4
83             WHERE id = $1",
84        )
85        .bind(&user.id)
86        .bind(&user.email)
87        .bind(user.email_verified)
88        .bind(&user.display_name)
89        .execute(&self.pool)
90        .await
91        .context("auth.users update")?;
92        Ok(())
93    }
94
95    async fn set_password_hash(&self, user_id: &str, hash: &str) -> Result<()> {
96        sqlx::query("UPDATE auth.users SET password_hash = $2 WHERE id = $1")
97            .bind(user_id)
98            .bind(hash)
99            .execute(&self.pool)
100            .await
101            .context("auth.users set password_hash")?;
102        Ok(())
103    }
104
105    async fn get_password_hash(&self, user_id: &str) -> Result<Option<String>> {
106        let row: Option<(Option<String>,)> =
107            sqlx::query_as("SELECT password_hash FROM auth.users WHERE id = $1")
108                .bind(user_id)
109                .fetch_optional(&self.pool)
110                .await
111                .context("auth.users select password_hash")?;
112        Ok(row.and_then(|r| r.0))
113    }
114
115    async fn list_passkeys(&self, user_id: &str) -> Result<Vec<PasskeyCred>> {
116        let rows = sqlx::query(
117            "SELECT credential_id, public_key, sign_count, transports, created_at
118             FROM auth.passkeys WHERE user_id = $1
119             ORDER BY created_at",
120        )
121        .bind(user_id)
122        .fetch_all(&self.pool)
123        .await
124        .context("auth.passkeys list")?;
125        Ok(rows.into_iter().map(map_passkey_row_pg).collect())
126    }
127
128    async fn add_passkey(&self, user_id: &str, cred: &PasskeyCred) -> Result<()> {
129        sqlx::query(
130            "INSERT INTO auth.passkeys
131                 (credential_id, user_id, public_key, sign_count, transports, created_at)
132             VALUES ($1, $2, $3, $4, $5, $6)",
133        )
134        .bind(&cred.credential_id)
135        .bind(user_id)
136        .bind(&cred.public_key)
137        .bind(cred.sign_count as i32)
138        .bind(cred.transports.join(","))
139        .bind(cred.created_at)
140        .execute(&self.pool)
141        .await
142        .context("auth.passkeys insert")?;
143        Ok(())
144    }
145
146    async fn remove_passkey(&self, credential_id: &[u8]) -> Result<bool> {
147        let res = sqlx::query("DELETE FROM auth.passkeys WHERE credential_id = $1")
148            .bind(credential_id)
149            .execute(&self.pool)
150            .await
151            .context("auth.passkeys delete")?;
152        Ok(res.rows_affected() > 0)
153    }
154
155    async fn link_upstream(&self, user_id: &str, provider: &str, subject: &str) -> Result<()> {
156        sqlx::query(
157            "INSERT INTO auth.user_upstream (provider, subject, user_id)
158             VALUES ($1, $2, $3)
159             ON CONFLICT (provider, subject) DO UPDATE SET user_id = EXCLUDED.user_id",
160        )
161        .bind(provider)
162        .bind(subject)
163        .bind(user_id)
164        .execute(&self.pool)
165        .await
166        .context("auth.user_upstream upsert")?;
167        Ok(())
168    }
169
170    async fn get_user_by_upstream(&self, provider: &str, subject: &str) -> Result<Option<User>> {
171        let row = sqlx::query(
172            "SELECT u.id, u.email, u.email_verified, u.display_name, u.created_at
173             FROM auth.users u
174             JOIN auth.user_upstream l ON l.user_id = u.id
175             WHERE l.provider = $1 AND l.subject = $2",
176        )
177        .bind(provider)
178        .bind(subject)
179        .fetch_optional(&self.pool)
180        .await
181        .context("auth.user_upstream lookup")?;
182        Ok(row.map(map_user_row_pg))
183    }
184
185    async fn list_users(&self, limit: i64, offset: i64, search: Option<&str>) -> Result<Vec<User>> {
186        // Cap limit at 500 — the admin dashboard pages 50 at a time so
187        // anything bigger is either a misconfigured client or a probe.
188        let lim = limit.clamp(1, 500);
189        let off = offset.max(0);
190        let rows = if let Some(needle) = search {
191            let pat = format!("%{}%", needle.to_lowercase());
192            sqlx::query(
193                "SELECT id, email, email_verified, display_name, created_at
194                 FROM auth.users
195                 WHERE LOWER(COALESCE(email, '')) LIKE $1
196                    OR LOWER(COALESCE(display_name, '')) LIKE $1
197                 ORDER BY created_at DESC
198                 LIMIT $2 OFFSET $3",
199            )
200            .bind(pat)
201            .bind(lim)
202            .bind(off)
203            .fetch_all(&self.pool)
204            .await
205            .context("auth.users list (search)")?
206        } else {
207            sqlx::query(
208                "SELECT id, email, email_verified, display_name, created_at
209                 FROM auth.users
210                 ORDER BY created_at DESC
211                 LIMIT $1 OFFSET $2",
212            )
213            .bind(lim)
214            .bind(off)
215            .fetch_all(&self.pool)
216            .await
217            .context("auth.users list")?
218        };
219        Ok(rows.into_iter().map(map_user_row_pg).collect())
220    }
221
222    async fn count_users(&self, search: Option<&str>) -> Result<i64> {
223        let row: (i64,) = if let Some(needle) = search {
224            let pat = format!("%{}%", needle.to_lowercase());
225            sqlx::query_as(
226                "SELECT COUNT(*) FROM auth.users
227                 WHERE LOWER(COALESCE(email, '')) LIKE $1
228                    OR LOWER(COALESCE(display_name, '')) LIKE $1",
229            )
230            .bind(pat)
231            .fetch_one(&self.pool)
232            .await
233            .context("auth.users count (search)")?
234        } else {
235            sqlx::query_as("SELECT COUNT(*) FROM auth.users")
236                .fetch_one(&self.pool)
237                .await
238                .context("auth.users count")?
239        };
240        Ok(row.0)
241    }
242
243    async fn delete_user(&self, id: &str) -> Result<bool> {
244        let res = sqlx::query("DELETE FROM auth.users WHERE id = $1")
245            .bind(id)
246            .execute(&self.pool)
247            .await
248            .context("auth.users delete")?;
249        Ok(res.rows_affected() > 0)
250    }
251
252    async fn list_upstream_for_user(&self, user_id: &str) -> Result<Vec<(String, String)>> {
253        let rows = sqlx::query(
254            "SELECT provider, subject FROM auth.user_upstream
255             WHERE user_id = $1 ORDER BY provider, subject",
256        )
257        .bind(user_id)
258        .fetch_all(&self.pool)
259        .await
260        .context("auth.user_upstream list")?;
261        Ok(rows
262            .into_iter()
263            .map(|r| {
264                (
265                    r.get::<String, _>("provider"),
266                    r.get::<String, _>("subject"),
267                )
268            })
269            .collect())
270    }
271}
272
273/// Session store backed by `auth.sessions`. Independent struct from
274/// [`PostgresUserStore`] because they're independently mockable in
275/// tests and the engine may swap one without the other.
276#[derive(Clone)]
277pub struct PostgresSessionStore {
278    pool: PgPool,
279}
280
281impl PostgresSessionStore {
282    pub fn new(pool: PgPool) -> Self {
283        Self { pool }
284    }
285
286    pub fn into_dyn(self) -> Arc<dyn SessionStore> {
287        Arc::new(self)
288    }
289}
290
291#[async_trait::async_trait]
292impl SessionStore for PostgresSessionStore {
293    async fn create(&self, session: &Session) -> Result<()> {
294        sqlx::query(
295            "INSERT INTO auth.sessions
296                 (id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash)
297             VALUES ($1, $2, $3, $4, $5, $6, $7)",
298        )
299        .bind(&session.id)
300        .bind(&session.user_id)
301        .bind(&session.csrf_token)
302        .bind(session.created_at)
303        .bind(session.expires_at)
304        .bind(&session.ip_hash)
305        .bind(&session.user_agent_hash)
306        .execute(&self.pool)
307        .await
308        .context("auth.sessions insert")?;
309        Ok(())
310    }
311
312    async fn get(&self, id: &str) -> Result<Option<Session>> {
313        let row = sqlx::query(
314            "SELECT id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash
315             FROM auth.sessions WHERE id = $1",
316        )
317        .bind(id)
318        .fetch_optional(&self.pool)
319        .await
320        .context("auth.sessions select")?;
321        Ok(row.map(map_session_row_pg))
322    }
323
324    async fn delete(&self, id: &str) -> Result<bool> {
325        let res = sqlx::query("DELETE FROM auth.sessions WHERE id = $1")
326            .bind(id)
327            .execute(&self.pool)
328            .await
329            .context("auth.sessions delete")?;
330        Ok(res.rows_affected() > 0)
331    }
332
333    async fn list_for_user(&self, user_id: &str) -> Result<Vec<Session>> {
334        let rows = sqlx::query(
335            "SELECT id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash
336             FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC",
337        )
338        .bind(user_id)
339        .fetch_all(&self.pool)
340        .await
341        .context("auth.sessions list_for_user")?;
342        Ok(rows.into_iter().map(map_session_row_pg).collect())
343    }
344
345    async fn delete_for_user(&self, user_id: &str) -> Result<u64> {
346        let res = sqlx::query("DELETE FROM auth.sessions WHERE user_id = $1")
347            .bind(user_id)
348            .execute(&self.pool)
349            .await
350            .context("auth.sessions delete_for_user")?;
351        Ok(res.rows_affected())
352    }
353
354    async fn purge_expired(&self, now: f64) -> Result<u64> {
355        let res = sqlx::query("DELETE FROM auth.sessions WHERE expires_at <= $1")
356            .bind(now)
357            .execute(&self.pool)
358            .await
359            .context("auth.sessions purge_expired")?;
360        Ok(res.rows_affected())
361    }
362
363    async fn list_all(
364        &self,
365        limit: i64,
366        offset: i64,
367        user_filter: Option<&str>,
368    ) -> Result<Vec<Session>> {
369        let lim = limit.clamp(1, 500);
370        let off = offset.max(0);
371        let rows = if let Some(uid) = user_filter {
372            sqlx::query(
373                "SELECT id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash
374                 FROM auth.sessions WHERE user_id = $1
375                 ORDER BY created_at DESC
376                 LIMIT $2 OFFSET $3",
377            )
378            .bind(uid)
379            .bind(lim)
380            .bind(off)
381            .fetch_all(&self.pool)
382            .await
383            .context("auth.sessions list_all (user filter)")?
384        } else {
385            sqlx::query(
386                "SELECT id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash
387                 FROM auth.sessions
388                 ORDER BY created_at DESC
389                 LIMIT $1 OFFSET $2",
390            )
391            .bind(lim)
392            .bind(off)
393            .fetch_all(&self.pool)
394            .await
395            .context("auth.sessions list_all")?
396        };
397        Ok(rows.into_iter().map(map_session_row_pg).collect())
398    }
399
400    async fn count_all(&self, user_filter: Option<&str>) -> Result<i64> {
401        let row: (i64,) = if let Some(uid) = user_filter {
402            sqlx::query_as("SELECT COUNT(*) FROM auth.sessions WHERE user_id = $1")
403                .bind(uid)
404                .fetch_one(&self.pool)
405                .await
406                .context("auth.sessions count_all (user filter)")?
407        } else {
408            sqlx::query_as("SELECT COUNT(*) FROM auth.sessions")
409                .fetch_one(&self.pool)
410                .await
411                .context("auth.sessions count_all")?
412        };
413        Ok(row.0)
414    }
415}
416
417fn map_user_row_pg(row: sqlx::postgres::PgRow) -> User {
418    User {
419        id: row.get("id"),
420        email: row.get("email"),
421        email_verified: row.get("email_verified"),
422        display_name: row.get("display_name"),
423        created_at: row.get("created_at"),
424    }
425}
426
427fn map_session_row_pg(row: sqlx::postgres::PgRow) -> Session {
428    Session {
429        id: row.get("id"),
430        user_id: row.get("user_id"),
431        csrf_token: row.get("csrf_token"),
432        created_at: row.get("created_at"),
433        expires_at: row.get("expires_at"),
434        ip_hash: row.get("ip_hash"),
435        user_agent_hash: row.get("user_agent_hash"),
436    }
437}
438
439fn map_passkey_row_pg(row: sqlx::postgres::PgRow) -> PasskeyCred {
440    let transports: String = row.get("transports");
441    let sign_count: i32 = row.get("sign_count");
442    PasskeyCred {
443        credential_id: row.get("credential_id"),
444        public_key: row.get("public_key"),
445        sign_count: sign_count.max(0) as u32,
446        transports: if transports.is_empty() {
447            Vec::new()
448        } else {
449            transports.split(',').map(|s| s.to_string()).collect()
450        },
451        created_at: row.get("created_at"),
452    }
453}