Skip to main content

tf_types/
policy_engine.rs

1//! Native TrustForge policy engine — Rust mirror of
2//! `tools/tf-types-ts/src/core/policy-engine.ts`.
3
4use std::collections::HashMap;
5
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10
11use crate::canonicalize;
12use crate::glob::glob_match;
13use crate::guard::NegativeCapability;
14
15#[derive(Clone, Debug, Serialize, Deserialize, Default)]
16pub struct PolicyQuery {
17    pub subject: String,
18    #[serde(default)]
19    pub instance: Option<String>,
20    pub action: String,
21    #[serde(default)]
22    pub target: Option<String>,
23    #[serde(default)]
24    pub context: HashMap<String, Value>,
25    #[serde(default)]
26    pub negative_capabilities: Vec<NegativeCapability>,
27    #[serde(default)]
28    pub enforcement_level: Option<String>,
29    #[serde(default)]
30    pub now: Option<String>,
31}
32
33#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
34pub struct PolicyDecision {
35    pub decision_version: String,
36    pub policy_engine: String,
37    pub engine_version: Option<String>,
38    pub trust_domain: String,
39    pub subject: String,
40    #[serde(skip_serializing_if = "Option::is_none", default)]
41    pub instance: Option<String>,
42    pub action: String,
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    pub target: Option<String>,
45    pub decision: String,
46    #[serde(skip_serializing_if = "Option::is_none", default)]
47    pub rule_id: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none", default)]
49    pub reason: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none", default)]
51    pub approval: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none", default)]
53    pub proof_required: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none", default)]
55    pub constraints_applied: Option<Vec<Value>>,
56    #[serde(skip_serializing_if = "Option::is_none", default)]
57    pub negative_capabilities_consulted: Option<Vec<NegativeCapability>>,
58    #[serde(skip_serializing_if = "Option::is_none", default)]
59    pub enforcement_level: Option<String>,
60    pub evaluated_at: String,
61    #[serde(skip_serializing_if = "Option::is_none", default)]
62    pub policy_manifest_hash: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none", default)]
64    pub context: Option<HashMap<String, Value>>,
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct PolicyRule {
69    pub id: String,
70    pub effect: String,
71    #[serde(default)]
72    pub action: Option<String>,
73    #[serde(default)]
74    pub action_pattern: Option<String>,
75    #[serde(default)]
76    pub subject_pattern: Option<String>,
77    #[serde(default)]
78    pub target_patterns: Option<Vec<String>>,
79    #[serde(default)]
80    pub approval: Option<String>,
81    #[serde(default)]
82    pub proof_required: Option<String>,
83    #[serde(default)]
84    pub constraints: Option<Vec<Value>>,
85    #[serde(default)]
86    pub reason: Option<String>,
87}
88
89#[derive(Clone, Debug, Serialize, Deserialize)]
90pub struct PolicyManifest {
91    pub policy_version: String,
92    pub trust_domain: String,
93    #[serde(default)]
94    pub engine_hint: Option<String>,
95    pub rules: Vec<PolicyRule>,
96    #[serde(default)]
97    pub negative_capabilities: Vec<NegativeCapability>,
98    #[serde(default)]
99    pub continuous_reevaluation: Option<ContinuousReeval>,
100    #[serde(default)]
101    pub quorum_defaults: Option<QuorumDefaults>,
102}
103
104#[derive(Clone, Debug, Serialize, Deserialize)]
105pub struct ContinuousReeval {
106    pub triggers: Vec<String>,
107}
108
109#[derive(Clone, Debug, Serialize, Deserialize)]
110pub struct QuorumDefaults {
111    pub min_approvers: u32,
112    pub of: Vec<String>,
113}
114
115/// A pluggable policy engine. Implemented by the native engine, the
116/// `tf-cedar` crate, and the `tf-rego` crate. Decoupling via a trait
117/// (rather than a feature-gated dependency on the adapter crates) lets
118/// `tf-types` stay lightweight while still letting the daemon dispatch
119/// the right engine for a given `engine_hint`.
120pub trait PolicyEngineImpl {
121    fn evaluate(&self, query: &PolicyQuery) -> PolicyDecision;
122}
123
124pub struct NativePolicyEngine {
125    policy: PolicyManifest,
126    manifest_hash: String,
127}
128
129impl PolicyEngineImpl for NativePolicyEngine {
130    fn evaluate(&self, query: &PolicyQuery) -> PolicyDecision {
131        NativePolicyEngine::evaluate(self, query)
132    }
133}
134
135/// Dispatch a `PolicyQuery` to the appropriate backend based on
136/// `engine_hint`. Pass an explicit backend for the hints that need one;
137/// `native` falls back to the supplied native engine. When the requested
138/// hint has no backend wired in (e.g. caller didn't construct a Cedar
139/// engine yet) the dispatcher returns a safe deny.
140///
141/// The signature uses `dyn` trait objects so callers don't have to leak
142/// the cedar / rego crate types through `tf-types`. `tf-cedar` and
143/// `tf-rego` each export an adapter that implements `PolicyEngineImpl`.
144pub fn evaluate_with_engine(
145    hint: Option<&str>,
146    native: &NativePolicyEngine,
147    cedar: Option<&dyn PolicyEngineImpl>,
148    rego: Option<&dyn PolicyEngineImpl>,
149    query: &PolicyQuery,
150) -> PolicyDecision {
151    match hint {
152        Some("cedar") => match cedar {
153            Some(eng) => eng.evaluate(query),
154            None => unavailable_decision("cedar", query, native.policy.trust_domain.as_str()),
155        },
156        Some("rego") => match rego {
157            Some(eng) => eng.evaluate(query),
158            None => unavailable_decision("rego", query, native.policy.trust_domain.as_str()),
159        },
160        _ => native.evaluate(query),
161    }
162}
163
164fn unavailable_decision(engine: &str, query: &PolicyQuery, trust_domain: &str) -> PolicyDecision {
165    PolicyDecision {
166        decision_version: "1".into(),
167        policy_engine: engine.into(),
168        engine_version: Some(format!("{engine}-stub")),
169        trust_domain: trust_domain.into(),
170        subject: query.subject.clone(),
171        instance: query.instance.clone(),
172        action: query.action.clone(),
173        target: query.target.clone(),
174        decision: "deny".into(),
175        rule_id: None,
176        reason: Some(format!(
177            "{engine} engine not configured for this dispatcher (no adapter supplied)"
178        )),
179        approval: None,
180        proof_required: None,
181        constraints_applied: None,
182        negative_capabilities_consulted: None,
183        enforcement_level: query.enforcement_level.clone(),
184        evaluated_at: now_iso8601(),
185        policy_manifest_hash: None,
186        context: if query.context.is_empty() {
187            None
188        } else {
189            Some(query.context.clone())
190        },
191    }
192}
193
194impl NativePolicyEngine {
195    pub fn new(policy: PolicyManifest) -> Self {
196        let canonical_value = serde_json::to_value(&policy).unwrap_or(Value::Null);
197        let canonical = canonicalize(&canonical_value).unwrap_or_default();
198        let digest: [u8; 32] = Sha256::digest(canonical.as_bytes()).into();
199        let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
200        let manifest_hash = format!("sha256-{}", hex);
201        NativePolicyEngine {
202            policy,
203            manifest_hash,
204        }
205    }
206
207    pub fn evaluate(&self, query: &PolicyQuery) -> PolicyDecision {
208        let now = query.now.clone().unwrap_or_else(now_iso8601);
209        let neg_caps = if query.negative_capabilities.is_empty() {
210            self.policy.negative_capabilities.clone()
211        } else {
212            query.negative_capabilities.clone()
213        };
214        for neg in &neg_caps {
215            if negative_matches(neg, query) {
216                return self.decision(
217                    query,
218                    "deny",
219                    neg.reason
220                        .clone()
221                        .unwrap_or_else(|| format!("denied by negative_capability {}", neg.name)),
222                    None,
223                    None,
224                    None,
225                    None,
226                    Some(&neg_caps),
227                    &now,
228                );
229            }
230        }
231        for rule in &self.policy.rules {
232            if !rule_matches(rule, query) {
233                continue;
234            }
235            let reason = rule
236                .reason
237                .clone()
238                .unwrap_or_else(|| format!("matched rule {}", rule.id));
239            match rule.effect.as_str() {
240                "allow" => {
241                    return self.decision(
242                        query,
243                        "allow",
244                        reason,
245                        Some(rule.id.clone()),
246                        rule.constraints.clone(),
247                        rule.proof_required.clone(),
248                        rule.approval.clone(),
249                        Some(&neg_caps),
250                        &now,
251                    );
252                }
253                "deny" => {
254                    return self.decision(
255                        query,
256                        "deny",
257                        reason,
258                        Some(rule.id.clone()),
259                        None,
260                        None,
261                        None,
262                        Some(&neg_caps),
263                        &now,
264                    );
265                }
266                "escalate" => {
267                    let decision = if rule.approval.as_deref() == Some("quorum") {
268                        "escalate"
269                    } else {
270                        "approval-required"
271                    };
272                    return self.decision(
273                        query,
274                        decision,
275                        reason,
276                        Some(rule.id.clone()),
277                        rule.constraints.clone(),
278                        rule.proof_required.clone(),
279                        rule.approval.clone().or_else(|| Some("required".into())),
280                        Some(&neg_caps),
281                        &now,
282                    );
283                }
284                "log_only" => {
285                    return self.decision(
286                        query,
287                        "log-only",
288                        reason,
289                        Some(rule.id.clone()),
290                        rule.constraints.clone(),
291                        rule.proof_required.clone(),
292                        None,
293                        Some(&neg_caps),
294                        &now,
295                    );
296                }
297                _ => continue,
298            }
299        }
300        self.decision(
301            query,
302            "deny",
303            "no matching rule (default deny)".into(),
304            None,
305            None,
306            None,
307            None,
308            Some(&neg_caps),
309            &now,
310        )
311    }
312
313    pub fn continuous_triggers(&self) -> Vec<String> {
314        self.policy
315            .continuous_reevaluation
316            .as_ref()
317            .map(|c| c.triggers.clone())
318            .unwrap_or_default()
319    }
320
321    pub fn quorum_defaults(&self) -> Option<&QuorumDefaults> {
322        self.policy.quorum_defaults.as_ref()
323    }
324
325    pub fn manifest_hash(&self) -> &str {
326        &self.manifest_hash
327    }
328
329    #[allow(clippy::too_many_arguments)]
330    fn decision(
331        &self,
332        query: &PolicyQuery,
333        decision: &str,
334        reason: String,
335        rule_id: Option<String>,
336        constraints: Option<Vec<Value>>,
337        proof: Option<String>,
338        approval: Option<String>,
339        neg_caps: Option<&[NegativeCapability]>,
340        now: &str,
341    ) -> PolicyDecision {
342        PolicyDecision {
343            decision_version: "1".into(),
344            policy_engine: "native".into(),
345            engine_version: Some("tf-policy-native-0.1.0".into()),
346            trust_domain: self.policy.trust_domain.clone(),
347            subject: query.subject.clone(),
348            instance: query.instance.clone(),
349            action: query.action.clone(),
350            target: query.target.clone(),
351            decision: decision.into(),
352            rule_id,
353            reason: Some(reason),
354            approval,
355            proof_required: proof,
356            constraints_applied: constraints.filter(|c| !c.is_empty()),
357            negative_capabilities_consulted: neg_caps.map(|c| c.to_vec()).filter(|v| !v.is_empty()),
358            enforcement_level: query.enforcement_level.clone(),
359            evaluated_at: now.into(),
360            policy_manifest_hash: Some(self.manifest_hash.clone()),
361            context: if query.context.is_empty() {
362                None
363            } else {
364                Some(query.context.clone())
365            },
366        }
367    }
368}
369
370fn rule_matches(rule: &PolicyRule, query: &PolicyQuery) -> bool {
371    if let Some(action) = &rule.action {
372        if action != &query.action {
373            return false;
374        }
375    }
376    if let Some(pattern) = &rule.action_pattern {
377        let re = match Regex::new(pattern) {
378            Ok(r) => r,
379            Err(_) => return false,
380        };
381        if !re.is_match(&query.action) {
382            return false;
383        }
384    }
385    if let Some(pattern) = &rule.subject_pattern {
386        let re = match Regex::new(pattern) {
387            Ok(r) => r,
388            Err(_) => return false,
389        };
390        if !re.is_match(&query.subject) {
391            return false;
392        }
393    }
394    if let Some(targets) = &rule.target_patterns {
395        if !targets.is_empty() {
396            let Some(target) = &query.target else {
397                return false;
398            };
399            if !targets.iter().any(|p| glob_match(p, target)) {
400                return false;
401            }
402        }
403    }
404    true
405}
406
407fn negative_matches(neg: &NegativeCapability, q: &PolicyQuery) -> bool {
408    if neg.name != q.action {
409        return false;
410    }
411    let Some(target_pattern) = neg.target.as_deref() else {
412        return true;
413    };
414    let Some(query_target) = q.target.as_deref() else {
415        return false;
416    };
417    glob_match(target_pattern, query_target)
418}
419
420
421fn now_iso8601() -> String {
422    let secs = std::time::SystemTime::now()
423        .duration_since(std::time::UNIX_EPOCH)
424        .unwrap_or_default()
425        .as_secs() as i64;
426    let (year, month, day, hour, minute, second) = secs_to_ymdhms(secs);
427    format!(
428        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
429        year, month, day, hour, minute, second
430    )
431}
432
433fn secs_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
434    let days = secs.div_euclid(86_400);
435    let time = secs.rem_euclid(86_400);
436    let hour = (time / 3600) as u32;
437    let minute = ((time % 3600) / 60) as u32;
438    let second = (time % 60) as u32;
439    let z = days + 719_468;
440    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
441    let doe = (z - era * 146_097) as u64;
442    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
443    let y = yoe as i64 + era * 400;
444    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
445    let mp = (5 * doy + 2) / 153;
446    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
447    let m = if mp < 10 {
448        (mp + 3) as u32
449    } else {
450        (mp - 9) as u32
451    };
452    let year = if m <= 2 { y + 1 } else { y };
453    (year as i32, m, d, hour, minute, second)
454}