Skip to main content

lenso_service/production_delivery/
policy.rs

1use std::collections::BTreeMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7use crate::extraction_input_digest;
8
9use super::{
10    ConfigContractDefinition, ConfigRevision, DeliveryDecision, DeliveryEffects, DeliveryIssue,
11    DeliveryIssueCode, ProductionEligibilityEvidence, ProductionEligibilityInput,
12    ReleaseTrustEvidence, ReleaseTrustProvider, SecretProvider, ServiceRelease,
13    config_revision_matches_contract, production_eligibility_evidence_integrity_is_valid,
14    release_trust_evidence_integrity_is_valid, service_release_integrity_is_valid,
15};
16
17pub const POLICY_PACK_PROTOCOL: &str = "lenso.policy-pack.v1";
18pub const POLICY_EVIDENCE_PROTOCOL: &str = "lenso.policy-evidence.v1";
19
20#[derive(
21    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
22)]
23#[serde(rename_all = "snake_case")]
24pub enum PolicyEnvironmentProfile {
25    Development,
26    Production,
27}
28
29#[derive(
30    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
31)]
32#[serde(rename_all = "snake_case")]
33pub enum PolicyRuleSeverity {
34    Required,
35    Advisory,
36}
37
38#[derive(
39    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
40)]
41#[serde(rename_all = "camelCase")]
42pub struct PolicyRule {
43    pub rule_id: String,
44    pub evidence_key: String,
45    pub severity: PolicyRuleSeverity,
46    pub advisory_in_development: bool,
47    pub remediation: String,
48    pub next_action: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
52#[serde(rename_all = "camelCase")]
53pub struct PolicyPack {
54    pub protocol: String,
55    pub pack_id: String,
56    pub pack_digest: String,
57    pub version: String,
58    pub environment_profile: PolicyEnvironmentProfile,
59    pub rules: Vec<PolicyRule>,
60}
61
62#[derive(
63    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
64)]
65#[serde(rename_all = "snake_case")]
66pub enum PolicyEvaluationSurface {
67    Local,
68    Ci,
69    Cli,
70    SystemPlane,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
74#[serde(rename_all = "camelCase")]
75pub struct PolicyRuleResult {
76    pub rule_id: String,
77    pub severity: PolicyRuleSeverity,
78    pub decision: DeliveryDecision,
79    pub evidence_references: Vec<String>,
80    pub remediation: String,
81    pub next_actions: Vec<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
85#[serde(rename_all = "camelCase")]
86pub struct PolicyEvidence {
87    pub protocol: String,
88    pub evidence_id: String,
89    pub evidence_digest: String,
90    pub pack_id: String,
91    pub pack_digest: String,
92    pub evaluated_subject: String,
93    pub input_digests: BTreeMap<String, String>,
94    pub decision: DeliveryDecision,
95    pub rule_results: Vec<PolicyRuleResult>,
96    pub issues: Vec<DeliveryIssue>,
97    pub effects: DeliveryEffects,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
101#[serde(rename_all = "camelCase")]
102pub struct DeliveryPolicyInputs {
103    pub release: ServiceRelease,
104    pub trust: ReleaseTrustEvidence,
105    pub config_contract: ConfigContractDefinition,
106    pub config: ConfigRevision,
107    pub eligibility: ProductionEligibilityEvidence,
108    pub eligibility_input: ProductionEligibilityInput,
109}
110
111#[must_use]
112pub fn production_policy_pack() -> PolicyPack {
113    build_policy_pack(PolicyEnvironmentProfile::Production)
114}
115
116#[must_use]
117pub fn development_policy_pack() -> PolicyPack {
118    build_policy_pack(PolicyEnvironmentProfile::Development)
119}
120
121#[must_use]
122pub fn evaluate_delivery_policy(
123    pack: &PolicyPack,
124    inputs: &DeliveryPolicyInputs,
125    trust_provider: &dyn ReleaseTrustProvider,
126    secret_provider: &dyn SecretProvider,
127    _surface: PolicyEvaluationSurface,
128) -> PolicyEvidence {
129    let pack_valid = policy_pack_integrity_is_valid(pack);
130    let release_valid = service_release_integrity_is_valid(&inputs.release);
131    let trust_valid =
132        release_trust_evidence_integrity_is_valid(&inputs.trust, &inputs.release, trust_provider);
133    let config_valid =
134        config_revision_matches_contract(&inputs.config, &inputs.config_contract, secret_provider)
135            && inputs.config.service_id == inputs.release.service_id
136            && inputs.config_contract.reference == inputs.release.config_contract.reference
137            && inputs.config_contract.digest == inputs.release.config_contract.digest
138            && inputs.config.contract_digest == inputs.release.config_contract.digest;
139    let eligibility_valid = production_eligibility_evidence_integrity_is_valid(
140        &inputs.eligibility,
141        &inputs.eligibility_input,
142        &inputs.release,
143        trust_provider,
144    );
145    let facts = BTreeMap::from([
146        ("release.integrity".to_owned(), Some(release_valid)),
147        (
148            "supply_chain.trusted".to_owned(),
149            Some(trust_valid && inputs.trust.decision == DeliveryDecision::Passed),
150        ),
151        ("config.valid".to_owned(), Some(config_valid)),
152    ])
153    .into_iter()
154    .chain(inputs.eligibility.facts.iter().map(|(key, value)| {
155        (
156            key.clone(),
157            if eligibility_valid {
158                *value
159            } else {
160                Some(false)
161            },
162        )
163    }))
164    .collect::<BTreeMap<_, _>>();
165
166    let mut issues = Vec::new();
167    if !pack_valid {
168        issues.push(DeliveryIssue {
169            code: DeliveryIssueCode::PolicyRuleBlocked,
170            message: "The Policy Pack identity, digest, environment profile, or required rule set is invalid.".to_owned(),
171            evidence_references: vec![pack.pack_id.clone()],
172            remediation: "Use the canonical versioned Policy Pack for the selected environment profile.".to_owned(),
173            next_actions: vec!["Reload the canonical Policy Pack and evaluate the unchanged evidence again.".to_owned()],
174        });
175    }
176    let rule_results = pack
177        .rules
178        .iter()
179        .map(|rule| {
180            let passed = facts
181                .get(&rule.evidence_key)
182                .copied()
183                .flatten()
184                .unwrap_or(false);
185            let decision = if passed {
186                DeliveryDecision::Passed
187            } else if pack.environment_profile == PolicyEnvironmentProfile::Development
188                && rule.advisory_in_development
189            {
190                DeliveryDecision::Advisory
191            } else {
192                DeliveryDecision::Blocked
193            };
194            if decision == DeliveryDecision::Blocked {
195                issues.push(DeliveryIssue {
196                    code: DeliveryIssueCode::PolicyRuleBlocked,
197                    message: format!(
198                        "Policy rule `{}` is blocked because `{}` is false or unknown.",
199                        rule.rule_id, rule.evidence_key
200                    ),
201                    evidence_references: vec![rule.evidence_key.clone()],
202                    remediation: rule.remediation.clone(),
203                    next_actions: vec![rule.next_action.clone()],
204                });
205            }
206            PolicyRuleResult {
207                rule_id: rule.rule_id.clone(),
208                severity: rule.severity,
209                decision,
210                evidence_references: vec![rule.evidence_key.clone()],
211                remediation: rule.remediation.clone(),
212                next_actions: vec![rule.next_action.clone()],
213            }
214        })
215        .collect::<Vec<_>>();
216    let decision = if !pack_valid
217        || rule_results
218            .iter()
219            .any(|result| result.decision == DeliveryDecision::Blocked)
220    {
221        DeliveryDecision::Blocked
222    } else if rule_results
223        .iter()
224        .any(|result| result.decision == DeliveryDecision::Advisory)
225    {
226        DeliveryDecision::Advisory
227    } else {
228        DeliveryDecision::Passed
229    };
230    let input_digests = BTreeMap::from([
231        ("release".to_owned(), inputs.release.release_digest.clone()),
232        ("trust".to_owned(), inputs.trust.evidence_digest.clone()),
233        ("config".to_owned(), inputs.config.revision_digest.clone()),
234        (
235            "eligibility".to_owned(),
236            inputs.eligibility.evidence_digest.clone(),
237        ),
238    ]);
239    #[derive(Serialize)]
240    struct EvidenceContent<'a> {
241        protocol: &'a str,
242        pack_id: &'a str,
243        pack_digest: &'a str,
244        evaluated_subject: &'a str,
245        input_digests: &'a BTreeMap<String, String>,
246        decision: DeliveryDecision,
247        rule_results: &'a [PolicyRuleResult],
248        issues: &'a [DeliveryIssue],
249    }
250    let content = EvidenceContent {
251        protocol: POLICY_EVIDENCE_PROTOCOL,
252        pack_id: &pack.pack_id,
253        pack_digest: &pack.pack_digest,
254        evaluated_subject: &inputs.release.release_id,
255        input_digests: &input_digests,
256        decision,
257        rule_results: &rule_results,
258        issues: &issues,
259    };
260    let evidence_digest = extraction_input_digest(
261        serde_json::to_vec(&content).expect("policy evidence must serialize"),
262    );
263    PolicyEvidence {
264        protocol: POLICY_EVIDENCE_PROTOCOL.to_owned(),
265        evidence_id: format!("policy-evidence:{evidence_digest}"),
266        evidence_digest,
267        pack_id: pack.pack_id.clone(),
268        pack_digest: pack.pack_digest.clone(),
269        evaluated_subject: inputs.release.release_id.clone(),
270        input_digests,
271        decision,
272        rule_results,
273        issues,
274        effects: DeliveryEffects::default(),
275    }
276}
277
278#[must_use]
279pub fn policy_pack_integrity_is_valid(pack: &PolicyPack) -> bool {
280    *pack == build_policy_pack(pack.environment_profile)
281}
282
283#[must_use]
284pub fn policy_evidence_integrity_is_valid(evidence: &PolicyEvidence) -> bool {
285    #[derive(Serialize)]
286    struct EvidenceContent<'a> {
287        protocol: &'a str,
288        pack_id: &'a str,
289        pack_digest: &'a str,
290        evaluated_subject: &'a str,
291        input_digests: &'a BTreeMap<String, String>,
292        decision: DeliveryDecision,
293        rule_results: &'a [PolicyRuleResult],
294        issues: &'a [DeliveryIssue],
295    }
296    let content = EvidenceContent {
297        protocol: evidence.protocol.as_str(),
298        pack_id: evidence.pack_id.as_str(),
299        pack_digest: evidence.pack_digest.as_str(),
300        evaluated_subject: evidence.evaluated_subject.as_str(),
301        input_digests: &evidence.input_digests,
302        decision: evidence.decision,
303        rule_results: &evidence.rule_results,
304        issues: &evidence.issues,
305    };
306    evidence.protocol == POLICY_EVIDENCE_PROTOCOL
307        && evidence.evidence_id == format!("policy-evidence:{}", evidence.evidence_digest)
308        && extraction_input_digest(
309            serde_json::to_vec(&content).expect("policy evidence must serialize"),
310        ) == evidence.evidence_digest
311}
312
313#[must_use]
314pub fn production_policy_evidence_is_valid(
315    evidence: &PolicyEvidence,
316    inputs: &DeliveryPolicyInputs,
317    trust_provider: &dyn ReleaseTrustProvider,
318    secret_provider: &dyn SecretProvider,
319) -> bool {
320    evidence
321        == &evaluate_delivery_policy(
322            &production_policy_pack(),
323            inputs,
324            trust_provider,
325            secret_provider,
326            PolicyEvaluationSurface::SystemPlane,
327        )
328}
329
330fn build_policy_pack(environment_profile: PolicyEnvironmentProfile) -> PolicyPack {
331    let required_keys = [
332        "release.integrity",
333        "supply_chain.trusted",
334        "config.valid",
335        "contracts.compatible",
336        "migrations.safe",
337        "workflows.compatible",
338        "rollback.safe",
339        "providers.compatible",
340        "identity.production",
341        "tenancy.mode.production",
342        "tenancy.enforced",
343        "call_policies.declared",
344        "dependencies.ready",
345        "resilience.declared",
346        "reliability.complete",
347        "edge.valid",
348        "environment.verification_fresh",
349        "production.eligible",
350    ];
351    let rules = required_keys
352        .into_iter()
353        .map(|key| PolicyRule {
354            rule_id: format!("lenso.production.{}", key.replace('_', "-")),
355            evidence_key: key.to_owned(),
356            severity: PolicyRuleSeverity::Required,
357            advisory_in_development: matches!(
358                key,
359                "rollback.safe"
360                    | "providers.compatible"
361                    | "identity.production"
362                    | "tenancy.mode.production"
363                    | "environment.verification_fresh"
364                    | "production.eligible"
365            ),
366            remediation: format!("Provide passing canonical evidence for `{key}`."),
367            next_action: "Refresh canonical evidence and evaluate the same Policy Pack again."
368                .to_owned(),
369        })
370        .collect::<Vec<_>>();
371    let version = "v1".to_owned();
372    let pack_digest = extraction_input_digest(
373        serde_json::to_vec(&(
374            POLICY_PACK_PROTOCOL,
375            version.as_str(),
376            environment_profile,
377            rules.as_slice(),
378        ))
379        .expect("Policy Pack must serialize"),
380    );
381    PolicyPack {
382        protocol: POLICY_PACK_PROTOCOL.to_owned(),
383        pack_id: format!("policy-pack:{pack_digest}"),
384        pack_digest,
385        version,
386        environment_profile,
387        rules,
388    }
389}