dwara_core/ai/
governance.rs

1//! AI model governance (DW-084): per-team model allowlists and the
2//! shadow-audit switch.
3//!
4//! A team is a POLICY name (the same vocabulary the token budgets use
5//! for `scope: policy`): every consumer attaching a policy that
6//! carries an allowlist entry is restricted to that allowlist's
7//! client-facing model aliases. The check runs BEFORE routing, against
8//! the alias the client put in the request body, so a disallowed (or
9//! typo'd) alias is blocked at the edge with a 403
10//! `model_denied_by_policy` rather than surfacing as a provider 404.
11//!
12//! # Resolution (deny-wins intersection)
13//!
14//! The binding allowlists for one request are EVERY policy in the
15//! frozen precedence chain (consumer > route > service > listener >
16//! global) that has a `team_allowlists` entry. The model must be in
17//! ALL of them — the intersection, deny-anywhere-wins (the same
18//! principle as authz). A consumer with no binding allowlist policy
19//! is unrestricted (fail-open, the DW-017 default posture).
20//!
21//! # Audit
22//!
23//! When `ai.governance.audit` is true, BOTH allowed and denied
24//! attempts are recorded into the `ai_governance_events` analytics
25//! table for shadow review (the admin `/analytics/governance-audit`
26//! endpoint reads it). When false, only the denial metric fires — no
27//! per-event audit rows. Denied requests are ALWAYS audited when the
28//! governance block is present (the done-when requirement: a blocked
29//! attempt appears in the audit log); the `audit` flag extends
30//! recording to ALLOWED calls for spend-by-team shadow review.
31//!
32//! # Dependency direction
33//!
34//! `ai` depends on `config` only (see `scripts/check_deps.py`); this
35//! module reads `config::ai::AiConfig` and nothing else. The audit
36//! RECORD DTO lives in `analytics` (a plain struct, not importing
37//! `ai`) — the dataplane converts at the call site, keeping the
38//! dependency direction downward.
39
40use crate::config::ai::AiConfig;
41use std::collections::{BTreeMap, BTreeSet};
42
43/// The per-generation compiled governance rules (DW-084): the
44/// team_allowlists map plus the audit switch. Built at dataplane
45/// refresh from the published config; immutable once built. Stored on
46/// the dataplane behind an ArcSwap and swapped on reload, so a
47/// governance change applies to the next request with no restart.
48#[derive(Debug, Clone, Default)]
49pub struct GovernanceEngine {
50    /// Policy name -> its allowed model aliases (config-declared).
51    allowlists: BTreeMap<String, BTreeSet<String>>,
52    /// Whether to record allowed calls too (shadow audit).
53    audit: bool,
54}
55
56/// The governance check verdict for one request.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum GovernanceVerdict {
59    /// The model alias is allowed (or no binding allowlist governs
60    /// the request — fail-open).
61    Allow,
62    /// The model alias is denied by a binding allowlist. `reason` is
63    /// the human-readable denial cause (the metric label and the audit
64    /// row's reason column).
65    Deny {
66        /// The denying policy (team) name — the FIRST binding
67        /// allowlist that did not contain the alias (deny-wins walks
68        /// the chain in precedence order).
69        policy: String,
70        /// A short reason string (the metric label value).
71        reason: String,
72    },
73}
74
75impl GovernanceVerdict {
76    /// Whether this is a denial.
77    pub fn is_deny(&self) -> bool {
78        matches!(self, GovernanceVerdict::Deny { .. })
79    }
80
81    /// The reason label for the denial metric (empty for an allow).
82    pub fn reason_label(&self) -> &str {
83        match self {
84            GovernanceVerdict::Allow => "",
85            GovernanceVerdict::Deny { reason, .. } => reason,
86        }
87    }
88}
89
90impl GovernanceEngine {
91    /// Compile from the `ai:` config block's governance map. Absent
92    /// or empty governance yields an empty engine (no allowlists ->
93    /// every consumer is unrestricted, fail-open).
94    pub fn compile(cfg: Option<&AiConfig>) -> Self {
95        let Some(cfg) = cfg else {
96            return GovernanceEngine::default();
97        };
98        let Some(gov) = &cfg.governance else {
99            return GovernanceEngine::default();
100        };
101        let allowlists = gov
102            .team_allowlists
103            .iter()
104            .map(|(policy, models)| {
105                (
106                    policy.clone(),
107                    models.iter().cloned().collect::<BTreeSet<String>>(),
108                )
109            })
110            .collect();
111        GovernanceEngine {
112            allowlists,
113            audit: gov.audit,
114        }
115    }
116
117    /// Whether the engine carries any allowlists (cheap dataplane
118    /// skip — an empty engine allows everything and records nothing).
119    pub fn is_empty(&self) -> bool {
120        self.allowlists.is_empty()
121    }
122
123    /// Whether to record ALLOWED calls into the audit table (the
124    /// shadow-audit switch). Denied calls are always recorded when the
125    /// engine is non-empty.
126    pub fn audit(&self) -> bool {
127        self.audit
128    }
129
130    /// Check one request's model alias against the binding allowlists.
131    /// Walks the frozen precedence chain (consumer > route > service >
132    /// listener > global) and, for every policy that has an allowlist
133    /// entry, requires the alias to be in it (deny-wins intersection).
134    /// A consumer with no binding allowlist policy is allowed
135    /// (fail-open).
136    ///
137    /// `consumer` is the authenticated consumer name (None for an
138    /// anonymous request — anonymous requests have no consumer-level
139    /// policies, so the walk starts at the route level).
140    #[allow(clippy::too_many_arguments)]
141    pub fn check(
142        &self,
143        consumer: Option<&str>,
144        consumer_policies: &[String],
145        route_policies: &[String],
146        service_policies: &[String],
147        listener_policies: &[String],
148        global_policies: &[String],
149        model_alias: &str,
150    ) -> GovernanceVerdict {
151        if self.allowlists.is_empty() {
152            return GovernanceVerdict::Allow;
153        }
154        // Walk the precedence chain. For each policy that has an
155        // allowlist, the alias must be in it — deny-wins. The FIRST
156        // denying policy is named in the verdict (the most specific
157        // level, matching the chain order).
158        let levels: [&[String]; 5] = [
159            consumer_policies,
160            route_policies,
161            service_policies,
162            listener_policies,
163            global_policies,
164        ];
165        let _ = consumer; // consumer name is not a policy; policies carry the allowlists
166        for level in levels {
167            for name in level {
168                if let Some(allowlist) = self.allowlists.get(name) {
169                    if !allowlist.contains(model_alias) {
170                        return GovernanceVerdict::Deny {
171                            policy: name.clone(),
172                            reason: "model_not_in_team_allowlist".to_string(),
173                        };
174                    }
175                }
176            }
177        }
178        GovernanceVerdict::Allow
179    }
180}