1use 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
115pub 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
135pub 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}