arcly-http 0.1.2

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! Attribute-Based Access Control (ABAC) policy engine — hot-reloadable,
//! default-deny, zero locks on the request path.
//!
//! ## Why RBAC isn't enough at Tier-1
//!
//! `PermissionGuard` answers "does this *role* hold `orders:refund`?". A
//! fintech audit asks "may *this* senior agent refund *this much* for *this*
//! tenant *right now*?" — a decision over **attributes** of the subject,
//! resource, and environment. Those rules change with compliance reviews,
//! not deploy cycles, so they must live outside the binary and hot-reload.
//!
//! ## Zero-lock mechanics
//!
//! The whole rule set sits behind one `ArcSwap<PolicySet>` (the same proven
//! pattern as secret rotation and dynamic tenants): evaluation is one atomic
//! pointer load, a `HashMap` lookup, and pure compiled predicates. Reloads
//! (file watcher, OPA bundle poller, admin endpoint) build a fresh
//! `PolicySet` off-path and swap it in — version-monotonic, so stale loads
//! are ignored. **No policy-agent network call ever happens on the hot
//! path.**
//!
//! ## Usage
//!
//! ```ignore
//! // boot:
//! ctx.provide(PolicyEngine::new(my_rules_v1()));
//!
//! // route-level (macro): all listed actions must Permit
//! #[Post("/:id/refund", security("bearer"))]
//! #[RequirePolicies("orders.refund")]
//! async fn refund(ctx: RequestContext, #[Body] dto: RefundDto) -> Result<Json<Value>, HttpException> {
//!     // fine-grained re-check with resource attributes:
//!     policy::check_policies(&ctx, &["orders.refund.amount"],
//!         serde_json::json!({ "amount_cents": dto.amount_cents }))?;
//!     /* ... */
//! }
//! ```

use std::collections::HashMap;
use std::sync::Arc;

use arc_swap::ArcSwap;

use crate::web::{Error, RequestContext};

// ─── Decision model ───────────────────────────────────────────────────────────

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Decision {
    Permit,
    Deny,
}

/// Everything a rule may inspect for one decision.
pub struct PolicyInput<'a> {
    /// Dotted action name, e.g. `"orders.refund"`.
    pub action: &'a str,
    /// The authenticated principal's JWT claims.
    pub subject: &'a serde_json::Map<String, serde_json::Value>,
    pub tenant: Option<&'a str>,
    /// Handler-supplied resource attributes (`Null` for route-level checks).
    pub resource: serde_json::Value,
    pub env: EnvAttributes,
}

pub struct EnvAttributes {
    pub unix_now: u64,
    pub route: &'static str,
    pub method: String,
}

impl PolicyInput<'_> {
    /// Convenience: a string claim from the subject.
    pub fn subject_str(&self, key: &str) -> Option<&str> {
        self.subject.get(key).and_then(|v| v.as_str())
    }
    /// Convenience: a numeric resource attribute.
    pub fn resource_i64(&self, key: &str) -> Option<i64> {
        self.resource.get(key).and_then(|v| v.as_i64())
    }
}

// ─── Rules & sets ─────────────────────────────────────────────────────────────

/// One compiled rule: a pure predicate — no I/O, no locks, no await.
pub struct CompiledRule {
    pub effect: Decision,
    pub when: Arc<dyn Fn(&PolicyInput) -> bool + Send + Sync>,
}

/// An immutable, versioned rule set. **Default-deny**: actions with no
/// matching rule are denied — the zero-trust posture auditors expect.
pub struct PolicySet {
    pub version: u64,
    by_action: HashMap<String, Vec<CompiledRule>>,
}

impl PolicySet {
    pub fn new(version: u64) -> Self {
        Self {
            version,
            by_action: HashMap::new(),
        }
    }

    /// Register a rule for `action`. Rules evaluate in insertion order;
    /// first match wins.
    pub fn rule(
        mut self,
        action: &str,
        effect: Decision,
        when: impl Fn(&PolicyInput) -> bool + Send + Sync + 'static,
    ) -> Self {
        self.by_action
            .entry(action.to_owned())
            .or_default()
            .push(CompiledRule {
                effect,
                when: Arc::new(when),
            });
        self
    }

    fn evaluate(&self, input: &PolicyInput) -> Decision {
        let Some(rules) = self.by_action.get(input.action) else {
            return Decision::Deny; // default-deny
        };
        for rule in rules {
            if (rule.when)(input) {
                return rule.effect;
            }
        }
        Decision::Deny
    }
}

// ─── Source & engine ──────────────────────────────────────────────────────────

/// External policy origin (rules file, OPA bundle endpoint, DB) — the app
/// implements it; a background watcher feeds [`PolicyEngine::reload`].
pub trait PolicySource: Send + Sync + 'static {
    fn fetch(&self) -> futures::future::BoxFuture<'_, Result<PolicySet, String>>;
}

/// Hot-swappable decision point. Provide via `ctx.provide(PolicyEngine::new(…))`.
pub struct PolicyEngine {
    set: ArcSwap<PolicySet>,
}

impl PolicyEngine {
    pub fn new(initial: PolicySet) -> Self {
        Self {
            set: ArcSwap::from_pointee(initial),
        }
    }

    /// Swap in a new rule set — effective on the very next evaluation.
    /// Stale (≤ current) versions are ignored, so concurrent watchers and
    /// duplicate bundle delivery are harmless.
    pub fn reload(&self, next: PolicySet) {
        let current = self.set.load().version;
        if next.version <= current {
            tracing::warn!(
                current,
                offered = next.version,
                "ignoring stale policy reload"
            );
            return;
        }
        tracing::info!(version = next.version, "policy set reloaded (live)");
        self.set.store(Arc::new(next));
    }

    /// Hot path: one atomic load + map read + pure predicates.
    pub fn evaluate(&self, input: &PolicyInput) -> Decision {
        self.set.load().evaluate(input)
    }

    pub fn version(&self) -> u64 {
        self.set.load().version
    }
}

// ─── Check helper (macro entry point + handler-level re-checks) ───────────────

/// Evaluate every `action` against the engine; all must `Permit`.
///
/// `401` when unauthenticated, `403` when the engine is absent (zero-trust:
/// no engine = no permits) or any action denies.
pub fn check_policies(
    ctx: &RequestContext,
    actions: &[&'static str],
    resource: serde_json::Value,
) -> Result<(), Error> {
    let claims = ctx.claims().ok_or(Error::Unauthorized)?;
    let engine = ctx.try_inject::<PolicyEngine>().ok_or(Error::Forbidden)?;

    for action in actions {
        let input = PolicyInput {
            action,
            subject: claims,
            tenant: ctx.tenant().map(|t| t.id.as_str()),
            resource: resource.clone(),
            env: EnvAttributes {
                unix_now: crate::auth::session::unix_now(),
                route: ctx.route(),
                method: ctx.method().to_string(),
            },
        };
        if engine.evaluate(&input) != Decision::Permit {
            metrics::counter!("policy_denials_total", "action" => *action).increment(1);
            return Err(Error::Forbidden);
        }
    }
    Ok(())
}