arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! Consent capture & withdrawal (GDPR / privacy). A [`ConsentStore`] records
//! which purposes a subject has agreed to, with a timestamp and version, so the
//! app can prove lawful basis and honour withdrawal.

use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::error::Result;

/// A single consent grant for one purpose.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsentGrant {
    pub purpose: String,
    /// Version of the policy/notice the subject agreed to.
    pub version: String,
    pub granted: bool,
    pub timestamp: u64,
}

/// Storage for consent records. Back with an append-only table so withdrawals
/// are recorded rather than overwriting history (audit requirement).
#[async_trait]
pub trait ConsentStore: Send + Sync + 'static {
    async fn record(&self, user_id: &str, grant: ConsentGrant) -> Result<()>;
    /// Latest state per purpose for the subject.
    async fn current(&self, user_id: &str) -> Result<Vec<ConsentGrant>>;
    /// Whether the subject currently consents to `purpose`.
    async fn has_consent(&self, user_id: &str, purpose: &str) -> Result<bool> {
        Ok(self
            .current(user_id)
            .await?
            .into_iter()
            .find(|g| g.purpose == purpose)
            .map(|g| g.granted)
            .unwrap_or(false))
    }
}

pub fn now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// In-memory [`ConsentStore`] keeping full history — tests / dev only.
#[derive(Default)]
pub struct MemoryConsentStore {
    inner: RwLock<HashMap<String, Vec<ConsentGrant>>>,
}
impl MemoryConsentStore {
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl ConsentStore for MemoryConsentStore {
    async fn record(&self, user_id: &str, grant: ConsentGrant) -> Result<()> {
        self.inner
            .write()
            .unwrap()
            .entry(user_id.to_owned())
            .or_default()
            .push(grant);
        Ok(())
    }

    async fn current(&self, user_id: &str) -> Result<Vec<ConsentGrant>> {
        let guard = self.inner.read().unwrap();
        let Some(history) = guard.get(user_id) else {
            return Ok(Vec::new());
        };
        // Collapse to the latest grant per purpose.
        let mut latest: HashMap<&str, &ConsentGrant> = HashMap::new();
        for g in history {
            latest
                .entry(g.purpose.as_str())
                .and_modify(|cur| {
                    if g.timestamp >= cur.timestamp {
                        *cur = g;
                    }
                })
                .or_insert(g);
        }
        Ok(latest.into_values().cloned().collect())
    }
}