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}