Skip to main content

canic_host/policy_gate/model/
mod.rs

1use crate::evidence_envelope::{
2    EvidenceEnvelopeV1, EvidenceTargetV1, ExitClassV1, InputFingerprintV1, PayloadSchemaRefV1,
3    PayloadSchemaStabilityV1,
4};
5use serde::{Deserialize, Serialize, de};
6use std::{collections::BTreeMap, path::Path};
7use thiserror::Error as ThisError;
8
9///
10/// PolicyGateError
11///
12#[derive(Debug, ThisError)]
13pub enum PolicyGateError {
14    #[error("invalid policy: {0}")]
15    InvalidPolicy(String),
16
17    #[error("failed to parse policy TOML: {0}")]
18    Toml(#[from] toml::de::Error),
19
20    #[error("failed to parse evidence envelope JSON: {0}")]
21    Json(#[from] serde_json::Error),
22
23    #[error("failed to fingerprint policy input: {0}")]
24    Io(#[from] std::io::Error),
25}
26
27///
28/// CiPolicyV1
29///
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
31#[serde(deny_unknown_fields)]
32pub struct CiPolicyV1 {
33    pub schema_version: u32,
34    pub envelope: PolicyEnvelopeRulesV1,
35    pub exit_class: PolicyExitClassRulesV1,
36    pub summary: Option<PolicySummaryRulesV1>,
37    pub build_provenance: Option<PolicyBuildProvenanceRulesV1>,
38    #[serde(default)]
39    pub required_input: Vec<PolicyRequiredInputRuleV1>,
40}
41
42///
43/// PolicyEnvelopeRulesV1
44///
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
46#[serde(deny_unknown_fields)]
47pub struct PolicyEnvelopeRulesV1 {
48    pub required_schema: String,
49    pub allowed_payload_schemas: Option<Vec<String>>,
50    pub allowed_payload_stability: Option<Vec<PayloadSchemaStabilityV1>>,
51}
52
53///
54/// PolicyExitClassRulesV1
55///
56#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
57#[serde(deny_unknown_fields)]
58pub struct PolicyExitClassRulesV1 {
59    pub allowed: Vec<ExitClassV1>,
60}
61
62///
63/// PolicySummaryRulesV1
64///
65#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
66#[serde(deny_unknown_fields)]
67pub struct PolicySummaryRulesV1 {
68    #[serde(default)]
69    pub fail_on_evidence_conflicts: bool,
70    #[serde(default)]
71    pub fail_on_blocked_actions: bool,
72    pub allow_missing_or_stale_evidence: Option<bool>,
73}
74
75///
76/// PolicyBuildProvenanceRulesV1
77///
78#[derive(Clone, Debug, Default, Eq, PartialEq)]
79pub struct PolicyBuildProvenanceRulesV1 {
80    pub(super) rules: Vec<PolicyBuildProvenanceRuleV1>,
81}
82
83///
84/// PolicyBuildProvenanceRuleV1
85///
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub(super) enum PolicyBuildProvenanceRuleV1 {
88    CleanSource,
89    CargoLock,
90    WasmGzip,
91    Sha256,
92    PackageIdentityMatchesTarget,
93}
94
95impl PolicyBuildProvenanceRulesV1 {
96    pub(super) fn is_enabled(&self, rule: PolicyBuildProvenanceRuleV1) -> bool {
97        self.rules.contains(&rule)
98    }
99}
100
101impl<'de> Deserialize<'de> for PolicyBuildProvenanceRulesV1 {
102    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
103    where
104        D: de::Deserializer<'de>,
105    {
106        const FIELDS: &[&str] = &[
107            "require_clean_source",
108            "require_cargo_lock",
109            "require_wasm_gzip",
110            "require_sha256",
111            "require_package_identity_matches_target",
112        ];
113        let values = BTreeMap::<String, bool>::deserialize(deserializer)?;
114        let mut rules = Vec::new();
115        for (key, enabled) in values {
116            let rule = match key.as_str() {
117                "require_clean_source" => PolicyBuildProvenanceRuleV1::CleanSource,
118                "require_cargo_lock" => PolicyBuildProvenanceRuleV1::CargoLock,
119                "require_wasm_gzip" => PolicyBuildProvenanceRuleV1::WasmGzip,
120                "require_sha256" => PolicyBuildProvenanceRuleV1::Sha256,
121                "require_package_identity_matches_target" => {
122                    PolicyBuildProvenanceRuleV1::PackageIdentityMatchesTarget
123                }
124                unknown => return Err(de::Error::unknown_field(unknown, FIELDS)),
125            };
126            if enabled {
127                rules.push(rule);
128            }
129        }
130        Ok(Self { rules })
131    }
132}
133
134///
135/// PolicyRequiredInputRuleV1
136///
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
138#[serde(deny_unknown_fields)]
139pub struct PolicyRequiredInputRuleV1 {
140    pub kind: String,
141    pub schema: Option<String>,
142}
143
144///
145/// PolicyGateRequest
146///
147#[derive(Clone, Debug, Eq, PartialEq)]
148pub struct PolicyGateRequest<'a> {
149    pub policy_source: &'a str,
150    pub policy_path: &'a Path,
151    pub envelope_path: &'a Path,
152    pub fingerprint_root: &'a Path,
153    pub envelope: EvidenceEnvelopeV1,
154}
155
156///
157/// ProjectEvidenceManifestGateRequest
158///
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct ProjectEvidenceManifestGateRequest<'a> {
161    pub policy_source: &'a str,
162    pub policy_path: &'a Path,
163    pub manifest_source: &'a str,
164    pub manifest_path: &'a Path,
165    pub fingerprint_root: &'a Path,
166}
167
168///
169/// ProjectEvidenceManifestV1
170///
171#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
172#[serde(deny_unknown_fields)]
173pub struct ProjectEvidenceManifestV1 {
174    pub schema_version: u32,
175    pub project: ProjectEvidenceManifestProjectV1,
176    pub evidence: Vec<ProjectEvidenceManifestEntryV1>,
177}
178
179///
180/// ProjectEvidenceManifestProjectV1
181///
182#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
183#[serde(deny_unknown_fields)]
184pub struct ProjectEvidenceManifestProjectV1 {
185    pub name: String,
186    pub root: String,
187}
188
189///
190/// ProjectEvidenceManifestEntryV1
191///
192#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
193#[serde(deny_unknown_fields)]
194pub struct ProjectEvidenceManifestEntryV1 {
195    pub kind: String,
196    pub path: String,
197    pub required: bool,
198    pub payload_schema: String,
199    pub target: ProjectEvidenceManifestTargetV1,
200}
201
202///
203/// ProjectEvidenceManifestTargetV1
204///
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[serde(deny_unknown_fields)]
207pub struct ProjectEvidenceManifestTargetV1 {
208    pub deployment: Option<String>,
209    pub fleet: Option<String>,
210    pub role: Option<String>,
211    pub profile: Option<String>,
212    pub network: Option<String>,
213}
214
215///
216/// PolicyGateReportV1
217///
218#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
219pub struct PolicyGateReportV1 {
220    pub schema_version: u32,
221    pub policy_schema_version: u32,
222    pub policy_file_fingerprint: InputFingerprintV1,
223    pub evaluated_envelope_fingerprint: InputFingerprintV1,
224    pub evaluated_envelope_exit_class: ExitClassV1,
225    pub evaluated_payload_schema: PayloadSchemaRefV1,
226    pub evaluated_target: EvidenceTargetV1,
227    pub policy_status: PolicyEvaluationStatusV1,
228    pub gate_exit_class: ExitClassV1,
229    pub requirements: Vec<PolicyRequirementV1>,
230    pub findings: Vec<PolicyFindingV1>,
231}
232
233///
234/// ProjectEvidenceGateReportV1
235///
236#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
237pub struct ProjectEvidenceGateReportV1 {
238    pub schema_version: u32,
239    pub manifest_schema_version: u32,
240    pub project_name: String,
241    pub policy_file_fingerprint: InputFingerprintV1,
242    pub manifest_file_fingerprint: InputFingerprintV1,
243    pub policy_status: PolicyEvaluationStatusV1,
244    pub gate_exit_class: ExitClassV1,
245    pub evidence: Vec<ProjectEvidenceGateEntryReportV1>,
246}
247
248///
249/// ProjectEvidenceGateEntryReportV1
250///
251#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
252pub struct ProjectEvidenceGateEntryReportV1 {
253    pub kind: String,
254    pub path: String,
255    pub required: bool,
256    pub expected_payload_schema: String,
257    pub expected_target: ProjectEvidenceManifestTargetV1,
258    pub status: PolicyEvaluationStatusV1,
259    pub gate_exit_class: ExitClassV1,
260    pub evaluated_envelope_fingerprint: Option<InputFingerprintV1>,
261    pub policy_report: Option<PolicyGateReportV1>,
262    pub findings: Vec<PolicyFindingV1>,
263}
264
265///
266/// PolicyRequirementV1
267///
268#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
269pub struct PolicyRequirementV1 {
270    pub requirement_id: String,
271    pub status: PolicyEvaluationStatusV1,
272    pub exit_class: ExitClassV1,
273    pub finding_codes: Vec<String>,
274}
275
276///
277/// PolicyFindingV1
278///
279#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
280pub struct PolicyFindingV1 {
281    pub code: String,
282    pub severity: PolicyFindingSeverityV1,
283    pub message: String,
284    pub requirement_id: Option<String>,
285    pub subject: Option<String>,
286    pub expected: Option<serde_json::Value>,
287    pub actual: Option<serde_json::Value>,
288    pub evidence_path: Option<String>,
289    pub target: Option<EvidenceTargetV1>,
290    pub related_input: Option<String>,
291}
292
293///
294/// PolicyFindingSeverityV1
295///
296#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
297#[serde(rename_all = "snake_case")]
298pub enum PolicyFindingSeverityV1 {
299    Info,
300    Warning,
301    Error,
302}
303
304///
305/// PolicyEvaluationStatusV1
306///
307#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
308#[serde(rename_all = "snake_case")]
309pub enum PolicyEvaluationStatusV1 {
310    Passed,
311    Failed,
312}
313
314impl ProjectEvidenceManifestTargetV1 {
315    pub(super) const fn has_selector(&self) -> bool {
316        self.deployment.is_some()
317            || self.fleet.is_some()
318            || self.role.is_some()
319            || self.profile.is_some()
320            || self.network.is_some()
321    }
322
323    pub(super) fn matches_envelope_target(&self, target: &EvidenceTargetV1) -> bool {
324        self.deployment
325            .as_ref()
326            .is_none_or(|expected| target.deployment.as_ref() == Some(expected))
327            && self
328                .fleet
329                .as_ref()
330                .is_none_or(|expected| target.fleet.as_ref() == Some(expected))
331            && self
332                .role
333                .as_ref()
334                .is_none_or(|expected| target.role.as_ref() == Some(expected))
335            && self
336                .profile
337                .as_ref()
338                .is_none_or(|expected| target.profile.as_ref() == Some(expected))
339            && self
340                .network
341                .as_ref()
342                .is_none_or(|expected| target.network.as_ref() == Some(expected))
343    }
344}
345
346impl PolicyFindingV1 {
347    pub(super) fn error(
348        code: &str,
349        message: impl Into<String>,
350        requirement_id: &str,
351        exit_class: ExitClassV1,
352    ) -> Self {
353        Self {
354            code: code.to_string(),
355            severity: PolicyFindingSeverityV1::Error,
356            message: message.into(),
357            requirement_id: Some(requirement_id.to_string()),
358            subject: Some(exit_class_subject(exit_class).to_string()),
359            expected: None,
360            actual: None,
361            evidence_path: None,
362            target: None,
363            related_input: None,
364        }
365    }
366
367    pub(super) fn warning(code: &str, message: impl Into<String>, requirement_id: &str) -> Self {
368        Self {
369            code: code.to_string(),
370            severity: PolicyFindingSeverityV1::Warning,
371            message: message.into(),
372            requirement_id: Some(requirement_id.to_string()),
373            subject: Some("success_with_warnings".to_string()),
374            expected: None,
375            actual: None,
376            evidence_path: None,
377            target: None,
378            related_input: None,
379        }
380    }
381
382    pub(super) fn expected(mut self, expected: serde_json::Value) -> Self {
383        self.expected = Some(expected);
384        self
385    }
386
387    pub(super) fn actual(mut self, actual: serde_json::Value) -> Self {
388        self.actual = Some(actual);
389        self
390    }
391
392    pub(super) fn exit_class(&self) -> ExitClassV1 {
393        match self.subject.as_deref() {
394            Some("evidence_conflict") => ExitClassV1::EvidenceConflict,
395            Some("missing_required_evidence") => ExitClassV1::MissingRequiredEvidence,
396            _ => ExitClassV1::BlockedByPolicy,
397        }
398    }
399}
400
401const fn exit_class_subject(exit_class: ExitClassV1) -> &'static str {
402    match exit_class {
403        ExitClassV1::EvidenceConflict => "evidence_conflict",
404        ExitClassV1::MissingRequiredEvidence => "missing_required_evidence",
405        _ => "blocked_by_policy",
406    }
407}