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 app: Option<String>,
210    pub fleet: Option<String>,
211    pub role: Option<String>,
212    pub profile: Option<String>,
213    pub environment: Option<String>,
214}
215
216///
217/// PolicyGateReportV1
218///
219#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
220pub struct PolicyGateReportV1 {
221    pub schema_version: u32,
222    pub policy_schema_version: u32,
223    pub policy_file_fingerprint: InputFingerprintV1,
224    pub evaluated_envelope_fingerprint: InputFingerprintV1,
225    pub evaluated_envelope_exit_class: ExitClassV1,
226    pub evaluated_payload_schema: PayloadSchemaRefV1,
227    pub evaluated_target: EvidenceTargetV1,
228    pub policy_status: PolicyEvaluationStatusV1,
229    pub gate_exit_class: ExitClassV1,
230    pub requirements: Vec<PolicyRequirementV1>,
231    pub findings: Vec<PolicyFindingV1>,
232}
233
234///
235/// ProjectEvidenceGateReportV1
236///
237#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
238pub struct ProjectEvidenceGateReportV1 {
239    pub schema_version: u32,
240    pub manifest_schema_version: u32,
241    pub project_name: String,
242    pub policy_file_fingerprint: InputFingerprintV1,
243    pub manifest_file_fingerprint: InputFingerprintV1,
244    pub policy_status: PolicyEvaluationStatusV1,
245    pub gate_exit_class: ExitClassV1,
246    pub evidence: Vec<ProjectEvidenceGateEntryReportV1>,
247}
248
249///
250/// ProjectEvidenceGateEntryReportV1
251///
252#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
253pub struct ProjectEvidenceGateEntryReportV1 {
254    pub kind: String,
255    pub path: String,
256    pub required: bool,
257    pub expected_payload_schema: String,
258    pub expected_target: ProjectEvidenceManifestTargetV1,
259    pub status: PolicyEvaluationStatusV1,
260    pub gate_exit_class: ExitClassV1,
261    pub evaluated_envelope_fingerprint: Option<InputFingerprintV1>,
262    pub policy_report: Option<PolicyGateReportV1>,
263    pub findings: Vec<PolicyFindingV1>,
264}
265
266///
267/// PolicyRequirementV1
268///
269#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
270pub struct PolicyRequirementV1 {
271    pub requirement_id: String,
272    pub status: PolicyEvaluationStatusV1,
273    pub exit_class: ExitClassV1,
274    pub finding_codes: Vec<String>,
275}
276
277///
278/// PolicyFindingV1
279///
280#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
281pub struct PolicyFindingV1 {
282    pub code: String,
283    pub severity: PolicyFindingSeverityV1,
284    pub message: String,
285    pub requirement_id: Option<String>,
286    pub subject: Option<String>,
287    pub expected: Option<serde_json::Value>,
288    pub actual: Option<serde_json::Value>,
289    pub evidence_path: Option<String>,
290    pub target: Option<EvidenceTargetV1>,
291    pub related_input: Option<String>,
292}
293
294///
295/// PolicyFindingSeverityV1
296///
297#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
298#[serde(rename_all = "snake_case")]
299pub enum PolicyFindingSeverityV1 {
300    Info,
301    Warning,
302    Error,
303}
304
305///
306/// PolicyEvaluationStatusV1
307///
308#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
309#[serde(rename_all = "snake_case")]
310pub enum PolicyEvaluationStatusV1 {
311    Passed,
312    Failed,
313}
314
315impl ProjectEvidenceManifestTargetV1 {
316    pub(super) const fn has_selector(&self) -> bool {
317        self.deployment.is_some()
318            || self.app.is_some()
319            || self.fleet.is_some()
320            || self.role.is_some()
321            || self.profile.is_some()
322            || self.environment.is_some()
323    }
324
325    pub(super) fn matches_envelope_target(&self, target: &EvidenceTargetV1) -> bool {
326        self.deployment
327            .as_ref()
328            .is_none_or(|expected| target.deployment.as_ref() == Some(expected))
329            && self
330                .app
331                .as_ref()
332                .is_none_or(|expected| target.app.as_ref() == Some(expected))
333            && self
334                .fleet
335                .as_ref()
336                .is_none_or(|expected| target.fleet.as_ref() == Some(expected))
337            && self
338                .role
339                .as_ref()
340                .is_none_or(|expected| target.role.as_ref() == Some(expected))
341            && self
342                .profile
343                .as_ref()
344                .is_none_or(|expected| target.profile.as_ref() == Some(expected))
345            && self
346                .environment
347                .as_ref()
348                .is_none_or(|expected| target.environment.as_ref() == Some(expected))
349    }
350}
351
352impl PolicyFindingV1 {
353    pub(super) fn error(
354        code: &str,
355        message: impl Into<String>,
356        requirement_id: &str,
357        exit_class: ExitClassV1,
358    ) -> Self {
359        Self {
360            code: code.to_string(),
361            severity: PolicyFindingSeverityV1::Error,
362            message: message.into(),
363            requirement_id: Some(requirement_id.to_string()),
364            subject: Some(exit_class.label().to_string()),
365            expected: None,
366            actual: None,
367            evidence_path: None,
368            target: None,
369            related_input: None,
370        }
371    }
372
373    pub(super) fn warning(code: &str, message: impl Into<String>, requirement_id: &str) -> Self {
374        Self {
375            code: code.to_string(),
376            severity: PolicyFindingSeverityV1::Warning,
377            message: message.into(),
378            requirement_id: Some(requirement_id.to_string()),
379            subject: Some(ExitClassV1::SuccessWithWarnings.label().to_string()),
380            expected: None,
381            actual: None,
382            evidence_path: None,
383            target: None,
384            related_input: None,
385        }
386    }
387
388    pub(super) fn expected(mut self, expected: serde_json::Value) -> Self {
389        self.expected = Some(expected);
390        self
391    }
392
393    pub(super) fn actual(mut self, actual: serde_json::Value) -> Self {
394        self.actual = Some(actual);
395        self
396    }
397
398    pub(super) fn exit_class(&self) -> ExitClassV1 {
399        match self.subject_exit_class() {
400            Some(ExitClassV1::EvidenceConflict) => ExitClassV1::EvidenceConflict,
401            Some(ExitClassV1::MissingRequiredEvidence) => ExitClassV1::MissingRequiredEvidence,
402            _ => ExitClassV1::BlockedByPolicy,
403        }
404    }
405
406    #[must_use]
407    pub fn subject_exit_class(&self) -> Option<ExitClassV1> {
408        self.subject.as_deref().and_then(ExitClassV1::from_label)
409    }
410}