arcly-http-identity 0.8.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
//! Bridge federated-login decisions onto the framework's hash-chained
//! [`AuditPipeline`] (H2).
//!
//! `FederationAudit::record(FederationEvent)` was demo-bridged to `tracing`,
//! which is neither durable nor tamper-evident. This adapter forwards each event
//! as an [`AuditRecord`], so social/SSO logins land on the same append-only,
//! SHA-256-chained trail as `#[AuditLog]` mutations — the posture an auditor
//! expects for authentication events.

use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use async_trait::async_trait;

use arcly_http_core::observability::audit::{AuditOutcome, AuditPipeline, AuditRecord};

use crate::federation::{FederationAudit, FederationDecision, FederationEvent};

/// A [`FederationAudit`] that writes to the core [`AuditPipeline`]. Provide the
/// same pipeline the rest of the app uses so all audit records share one chain.
pub struct AuditPipelineFederationSink {
    pipeline: Arc<AuditPipeline>,
}

impl AuditPipelineFederationSink {
    pub fn new(pipeline: Arc<AuditPipeline>) -> Self {
        Self { pipeline }
    }
}

fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Map a federation decision to a stable action string + audit outcome.
fn map_decision(decision: FederationDecision) -> (&'static str, AuditOutcome) {
    match decision {
        FederationDecision::ReturningLogin => ("federation.login", AuditOutcome::Success),
        FederationDecision::Provisioned => ("federation.provision", AuditOutcome::Success),
        FederationDecision::AutoLinked => ("federation.link", AuditOutcome::Success),
        FederationDecision::LinkRequired => ("federation.link_required", AuditOutcome::Denied),
        FederationDecision::Denied => ("federation.denied", AuditOutcome::Denied),
    }
}

#[async_trait]
impl FederationAudit for AuditPipelineFederationSink {
    async fn record(&self, event: FederationEvent) {
        let (action, outcome) = map_decision(event.decision);
        let status = match outcome {
            AuditOutcome::Success => 200,
            AuditOutcome::Denied => 403,
            AuditOutcome::Error => 500,
        };
        self.pipeline.record(AuditRecord {
            timestamp_ms: now_ms(),
            action,
            resource: "identity",
            actor_sub: event.user_id.clone(),
            actor_role: None,
            tenant: event.tenant.clone(),
            // Use the real request trace when the caller supplied one; empty
            // (not a synthesised value) when the event is off-request.
            trace_id: event.trace_id.clone().unwrap_or_default(),
            method: "POST".to_string(),
            // The external identity is the resource acted on — a legitimate
            // resource path, so provider correlation lives here, not in trace_id.
            path: format!("/federated/{}/{}", event.provider, event.provider_id),
            outcome,
            status,
            prev_hash: String::new(), // filled by the pipeline worker
        });
    }
}