//! RBAC policy loader for build-time auth pattern extraction. //! //! Reads `site/rbac.ncl` (via `nickel export`) and returns the URL patterns //! that are denied for anonymous users. These patterns are the **single source //! of truth** for route protection — no duplication in `config.ncl`. use anyhow::{Context, Result}; use serde::Deserialize; use std::path::Path; #[derive(Debug, Deserialize)] struct RbacConfig { defaults: RbacDefaults, } #[derive(Debug, Deserialize)] struct RbacDefaults { unauthenticated_allow: Vec, } #[derive(Debug, Deserialize)] struct RbacEntry { resource: String, pattern: String, outcome: String, } /// Return URL patterns denied for anonymous users (from `defaults.unauthenticated_allow`). /// /// Filters to `resource = "page"` + `outcome = "deny"` entries in the order they /// appear in the policy — the order matters for display but not for the generated /// pattern matcher, which checks all entries. /// /// Returns an empty vec if `rbac.ncl` is absent (non-fatal; no protected routes). pub fn load_rbac_anonymous_deny_patterns(manifest_dir: &Path) -> Result> { let rbac_ncl = manifest_dir.join("site").join("rbac.ncl"); if !rbac_ncl.exists() { return Ok(Vec::new()); } let json = rustelo_config::nickel::export_to_json(&rbac_ncl) .with_context(|| format!("Nickel export failed for {}", rbac_ncl.display()))?; let config: RbacConfig = serde_json::from_str(&json) .with_context(|| format!("JSON deserialisation failed for {}", rbac_ncl.display()))?; let patterns = config .defaults .unauthenticated_allow .into_iter() .filter(|e| e.resource == "page" && e.outcome == "deny") .map(|e| e.pattern) .collect(); Ok(patterns) }