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