Skip to main content

arcly_http_identity/
store.rs

1//! Storage traits — the app supplies the backends (Postgres, Redis, …). The
2//! crate links no database. An in-memory implementation ([`MemoryStore`]) is
3//! provided for tests, examples, and local development.
4
5use std::collections::HashMap;
6use std::sync::{Arc, RwLock};
7
8use async_trait::async_trait;
9use hmac::{Hmac, Mac};
10use sha2::Sha256;
11
12use crate::error::{IdentityError, Result};
13use crate::model::{AccountStatus, Identity, MfaState};
14
15/// Persistence for [`Identity`] records.
16///
17/// Implementations are responsible for encrypting PII at rest (e.g. via the
18/// compliance `CryptoVault`) and for enforcing the tenant + email uniqueness
19/// constraint. Lookups are tenant-scoped so the same email can exist in two
20/// tenants (B2B) while remaining unique within one.
21#[async_trait]
22pub trait UserStore: Send + Sync + 'static {
23    async fn find_by_id(&self, id: &str) -> Result<Option<Identity>>;
24
25    async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>>;
26
27    /// Insert a new identity. Must return [`IdentityError::AlreadyExists`] on a
28    /// tenant+email collision.
29    async fn insert(&self, identity: &Identity) -> Result<()>;
30
31    /// Overwrite an existing identity (profile edits, status transitions,
32    /// verification flags, MFA state).
33    async fn update(&self, identity: &Identity) -> Result<()>;
34
35    /// Set only the account status (lock / suspend / activate).
36    async fn set_status(&self, id: &str, status: AccountStatus) -> Result<()> {
37        if let Some(mut u) = self.find_by_id(id).await? {
38            u.status = status;
39            self.update(&u).await
40        } else {
41            Err(IdentityError::NotFound)
42        }
43    }
44
45    /// Update just the MFA enrolment summary (called after TOTP/passkey changes).
46    async fn set_mfa(&self, id: &str, mfa: MfaState) -> Result<()> {
47        if let Some(mut u) = self.find_by_id(id).await? {
48            u.mfa = mfa;
49            self.update(&u).await
50        } else {
51            Err(IdentityError::NotFound)
52        }
53    }
54}
55
56/// Persistence for password hashes, kept separate from the identity record so
57/// credentials never ride along with profile reads.
58#[async_trait]
59pub trait CredentialStore: Send + Sync + 'static {
60    /// Store (or replace) the PHC-string password hash for a user.
61    async fn set_password(&self, user_id: &str, phc: &str) -> Result<()>;
62
63    /// Fetch the stored PHC hash, if the user has a password set.
64    async fn get_password(&self, user_id: &str) -> Result<Option<String>>;
65
66    /// Remove the password credential (e.g. converting to passwordless-only).
67    async fn clear_password(&self, user_id: &str) -> Result<()>;
68}
69
70// ─── PII encryption-at-rest decorator ───────────────────────────────────────
71
72/// Encrypts/decrypts a single PII field. Implement over the compliance
73/// `CryptoVault` (per-subject AES-256-GCM + crypto-shred) or a KMS. The crate
74/// links no cipher — this is the seam, matching the framework's trait-for-I/O
75/// rule.
76pub trait PiiCipher: Send + Sync + 'static {
77    /// Encrypt `plaintext`, returning an opaque wire string safe to store.
78    fn encrypt(&self, plaintext: &str) -> Result<String>;
79    /// Decrypt a wire string produced by [`encrypt`](Self::encrypt).
80    fn decrypt(&self, ciphertext: &str) -> Result<String>;
81}
82
83const PII_EMAIL: &str = "_pii_email";
84const PII_PHONE: &str = "_pii_phone";
85
86/// Prefix on a stored blind index so it is visibly distinct from a plaintext
87/// email in the backing store. `$` cannot appear before the `@` of a real
88/// address here, so a value starting `bi$` is unambiguously an index.
89pub const BLIND_INDEX_PREFIX: &str = "bi$";
90
91/// A [`UserStore`] decorator that keeps **email/phone encrypted at rest** while
92/// preserving equality lookup via a keyed **blind index** (HMAC-SHA256).
93///
94/// The wrapped store never sees plaintext PII: on write, the real address is
95/// AES-encrypted into the identity's `attributes` and the indexed `email` field
96/// is replaced by its blind index; on read, the plaintext is transparently
97/// restored. `find_by_email` hashes the query with the same key, so exact-match
98/// lookup still works without exposing PII — closing the "plaintext PII in the
99/// store" gap while remaining GDPR-shreddable (drop the subject's key in the
100/// vault and the ciphertext is unrecoverable).
101///
102/// Storing the index in the `email` field is intrinsic to decorating an opaque
103/// `UserStore` (whose `find_by_email` matches on that field). To make the value
104/// unmistakably **not** a plaintext address — for anyone inspecting the backing
105/// store — the blind index is prefixed with [`BLIND_INDEX_PREFIX`]
106/// (`"bi$"`), which no real email can contain in that position.
107pub struct EncryptingUserStore {
108    inner: Arc<dyn UserStore>,
109    cipher: Arc<dyn PiiCipher>,
110    index_key: Vec<u8>,
111}
112
113impl EncryptingUserStore {
114    pub fn new(
115        inner: Arc<dyn UserStore>,
116        cipher: Arc<dyn PiiCipher>,
117        index_key: impl Into<Vec<u8>>,
118    ) -> Self {
119        Self {
120            inner,
121            cipher,
122            index_key: index_key.into(),
123        }
124    }
125
126    fn blind_index(&self, value: &str) -> String {
127        let mut mac =
128            Hmac::<Sha256>::new_from_slice(&self.index_key).expect("HMAC accepts any key length");
129        mac.update(value.trim().to_ascii_lowercase().as_bytes());
130        let hex: String = mac
131            .finalize()
132            .into_bytes()
133            .iter()
134            .map(|b| format!("{b:02x}"))
135            .collect();
136        format!("{BLIND_INDEX_PREFIX}{hex}")
137    }
138
139    /// Transform a caller-facing identity into its at-rest form (PII encrypted,
140    /// indexed field blinded).
141    fn protect(&self, id: &Identity) -> Result<Identity> {
142        let mut stored = id.clone();
143        if let Some(email) = &id.email {
144            stored
145                .attributes
146                .insert(PII_EMAIL.into(), self.cipher.encrypt(email)?.into());
147            stored.email = Some(self.blind_index(email));
148        }
149        if let Some(phone) = &id.phone {
150            stored
151                .attributes
152                .insert(PII_PHONE.into(), self.cipher.encrypt(phone)?.into());
153            stored.phone = None;
154        }
155        Ok(stored)
156    }
157
158    /// Restore plaintext PII from an at-rest identity.
159    fn restore(&self, mut stored: Identity) -> Result<Identity> {
160        if let Some(enc) = stored
161            .attributes
162            .remove(PII_EMAIL)
163            .and_then(|v| v.as_str().map(str::to_owned))
164        {
165            stored.email = Some(self.cipher.decrypt(&enc)?);
166        }
167        if let Some(enc) = stored
168            .attributes
169            .remove(PII_PHONE)
170            .and_then(|v| v.as_str().map(str::to_owned))
171        {
172            stored.phone = Some(self.cipher.decrypt(&enc)?);
173        }
174        Ok(stored)
175    }
176}
177
178#[async_trait]
179impl UserStore for EncryptingUserStore {
180    async fn find_by_id(&self, id: &str) -> Result<Option<Identity>> {
181        match self.inner.find_by_id(id).await? {
182            Some(u) => Ok(Some(self.restore(u)?)),
183            None => Ok(None),
184        }
185    }
186
187    async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>> {
188        // Look the address up by its blind index — the store never receives the
189        // plaintext address.
190        let idx = self.blind_index(email);
191        match self.inner.find_by_email(tenant, &idx).await? {
192            Some(u) => Ok(Some(self.restore(u)?)),
193            None => Ok(None),
194        }
195    }
196
197    async fn insert(&self, identity: &Identity) -> Result<()> {
198        self.inner.insert(&self.protect(identity)?).await
199    }
200
201    async fn update(&self, identity: &Identity) -> Result<()> {
202        self.inner.update(&self.protect(identity)?).await
203    }
204}
205
206// ─── In-memory reference implementation ─────────────────────────────────────
207
208/// Thread-safe in-memory store implementing every persistence trait. **For
209/// tests / examples / local dev only** — data is lost on restart and there is
210/// no encryption at rest.
211#[derive(Default)]
212pub struct MemoryStore {
213    users: RwLock<HashMap<String, Identity>>,
214    // (tenant, email) -> id  secondary index
215    by_email: RwLock<HashMap<String, String>>,
216    passwords: RwLock<HashMap<String, String>>,
217}
218
219impl MemoryStore {
220    pub fn new() -> Self {
221        Self::default()
222    }
223
224    fn email_key(tenant: Option<&str>, email: &str) -> String {
225        format!("{}::{}", tenant.unwrap_or("-"), email.to_ascii_lowercase())
226    }
227}
228
229#[async_trait]
230impl UserStore for MemoryStore {
231    async fn find_by_id(&self, id: &str) -> Result<Option<Identity>> {
232        Ok(self.users.read().unwrap().get(id).cloned())
233    }
234
235    async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>> {
236        let key = Self::email_key(tenant, email);
237        let id = self.by_email.read().unwrap().get(&key).cloned();
238        match id {
239            Some(id) => self.find_by_id(&id).await,
240            None => Ok(None),
241        }
242    }
243
244    async fn insert(&self, identity: &Identity) -> Result<()> {
245        let mut users = self.users.write().unwrap();
246        let mut by_email = self.by_email.write().unwrap();
247        if users.contains_key(&identity.id) {
248            return Err(IdentityError::AlreadyExists);
249        }
250        if let Some(email) = &identity.email {
251            let key = Self::email_key(identity.tenant.as_deref(), email);
252            if by_email.contains_key(&key) {
253                return Err(IdentityError::AlreadyExists);
254            }
255            by_email.insert(key, identity.id.clone());
256        }
257        users.insert(identity.id.clone(), identity.clone());
258        Ok(())
259    }
260
261    async fn update(&self, identity: &Identity) -> Result<()> {
262        let mut users = self.users.write().unwrap();
263        if !users.contains_key(&identity.id) {
264            return Err(IdentityError::NotFound);
265        }
266        // Refresh the email index in case the address changed.
267        if let Some(email) = &identity.email {
268            let key = Self::email_key(identity.tenant.as_deref(), email);
269            self.by_email
270                .write()
271                .unwrap()
272                .insert(key, identity.id.clone());
273        }
274        users.insert(identity.id.clone(), identity.clone());
275        Ok(())
276    }
277}
278
279#[async_trait]
280impl CredentialStore for MemoryStore {
281    async fn set_password(&self, user_id: &str, phc: &str) -> Result<()> {
282        self.passwords
283            .write()
284            .unwrap()
285            .insert(user_id.to_owned(), phc.to_owned());
286        Ok(())
287    }
288
289    async fn get_password(&self, user_id: &str) -> Result<Option<String>> {
290        Ok(self.passwords.read().unwrap().get(user_id).cloned())
291    }
292
293    async fn clear_password(&self, user_id: &str) -> Result<()> {
294        self.passwords.write().unwrap().remove(user_id);
295        Ok(())
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    /// Reversible test cipher (base64) — proves the decorator's transform, not
304    /// cryptographic strength.
305    struct B64Cipher;
306    impl PiiCipher for B64Cipher {
307        fn encrypt(&self, p: &str) -> Result<String> {
308            use base64::engine::general_purpose::STANDARD;
309            use base64::Engine;
310            Ok(format!("enc:{}", STANDARD.encode(p)))
311        }
312        fn decrypt(&self, c: &str) -> Result<String> {
313            use base64::engine::general_purpose::STANDARD;
314            use base64::Engine;
315            let raw = c
316                .strip_prefix("enc:")
317                .ok_or(IdentityError::Internal("bad ct".into()))?;
318            let bytes = STANDARD
319                .decode(raw)
320                .map_err(|_| IdentityError::Internal("b64".into()))?;
321            String::from_utf8(bytes).map_err(|_| IdentityError::Internal("utf8".into()))
322        }
323    }
324
325    #[tokio::test]
326    async fn encrypting_store_hides_plaintext_but_keeps_lookup() {
327        let inner = Arc::new(MemoryStore::new());
328        let enc =
329            EncryptingUserStore::new(inner.clone(), Arc::new(B64Cipher), b"index-key".to_vec());
330
331        let identity = Identity {
332            id: "u1".into(),
333            tenant: None,
334            email: Some("Alice@Corp.com".into()),
335            email_verified: true,
336            phone: Some("+15551234".into()),
337            phone_verified: false,
338            status: AccountStatus::Active,
339            roles: vec!["user".into()],
340            perms: vec![],
341            mfa: Default::default(),
342            attributes: Default::default(),
343        };
344        enc.insert(&identity).await.unwrap();
345
346        // The underlying store never holds the plaintext email/phone; the stored
347        // "email" is an obviously-non-plaintext blind index (prefixed).
348        let raw = inner.find_by_id("u1").await.unwrap().unwrap();
349        assert_ne!(raw.email.as_deref(), Some("Alice@Corp.com"));
350        assert!(raw
351            .email
352            .as_deref()
353            .unwrap()
354            .starts_with(BLIND_INDEX_PREFIX));
355        assert!(!raw.email.as_deref().unwrap().contains('@'));
356        assert!(raw.phone.is_none());
357        assert!(raw.attributes.contains_key("_pii_email"));
358
359        // Case-insensitive lookup by plaintext still resolves via the blind index.
360        let found = enc
361            .find_by_email(None, "alice@corp.com")
362            .await
363            .unwrap()
364            .unwrap();
365        assert_eq!(found.email.as_deref(), Some("Alice@Corp.com"));
366        assert_eq!(found.phone.as_deref(), Some("+15551234"));
367        assert!(!found.attributes.contains_key("_pii_email")); // stripped on read
368    }
369}