Skip to main content

clawft_kernel/workload_governance/
gate.rs

1//! [`WorkloadGate`]: the governance gate for `workload.*` actions.
2//!
3//! Evaluation order (every step's outcome is chained, ADR-022):
4//!
5//! 1. The action must be one of [`GOVERNED_ACTIONS`]; an unknown
6//!    `workload.*` action is refused and chained as `workload.refuse`.
7//! 2. The `workload` context object must parse and validate.
8//! 3. The subject revocation list must be readable, a request that installs,
9//!    places, loads, starts or migrates must name a package, signer key or
10//!    artifact hash, and none of those may be revoked. Teardown (stop,
11//!    unload, re-adoption) is never blocked by a revocation.
12//! 4. Workloads carrying secrets need a `pinned` node (ADR-099 s8.3).
13//! 5. A [`WorkloadPermitRule`] must match; otherwise the default deny holds.
14//!    This is enforced in code as well as by the `WORKLOAD-DEFAULT-DENY`
15//!    rule, so a rule set that lost that rule still fails closed.
16//! 6. The governance engine evaluates the derived 5D effect vector with
17//!    every rule except the default-deny category (effect ceiling and any
18//!    other distributed rules still apply).
19
20use std::sync::Arc;
21
22use super::effect::{NodeTrustTier, WorkloadRequest};
23use super::policy::{
24    DEFAULT_DENY_CATEGORY, DEFAULT_DENY_RULE_ID, GOVERNED_ACTIONS, WORKLOAD_ACTION_PREFIX,
25    WorkloadPermitRule, default_rules, is_governed_action,
26};
27use crate::chain::{self, ChainManager};
28use crate::gate::{GateBackend, GateDecision};
29use crate::governance::{
30    GatePrincipal, GovernanceDecision, GovernanceEngine, GovernanceRequest, GovernanceRule,
31    ProjectAttestation,
32};
33use crate::revocation::{RevocationKind, RevocationList};
34
35/// Chain source used for workload governance events.
36pub const CHAIN_SOURCE: &str = "workload";
37
38/// Default effect-magnitude threshold for workload actions.
39pub const DEFAULT_THRESHOLD: f64 = 0.8;
40
41/// Chain event kind for a governed action (`None` for unknown actions).
42pub fn event_kind_for(action: &str) -> Option<&'static str> {
43    Some(match action {
44        "workload.install" => chain::EVENT_KIND_WORKLOAD_INSTALL,
45        "workload.place" => chain::EVENT_KIND_WORKLOAD_PLACE,
46        "workload.load" => chain::EVENT_KIND_WORKLOAD_LOAD,
47        "workload.start" => chain::EVENT_KIND_WORKLOAD_START,
48        "workload.stop" => chain::EVENT_KIND_WORKLOAD_STOP,
49        "workload.unload" => chain::EVENT_KIND_WORKLOAD_UNLOAD,
50        "workload.migrate" => chain::EVENT_KIND_WORKLOAD_MIGRATE,
51        "workload.revoke" => chain::EVENT_KIND_WORKLOAD_REVOKE,
52        "workload.node.bind" => chain::EVENT_KIND_WORKLOAD_NODE_BIND,
53        "workload.node.unbind" => chain::EVENT_KIND_WORKLOAD_NODE_UNBIND,
54        _ => return None,
55    })
56}
57
58/// Governance gate for workload actions.
59pub struct WorkloadGate {
60    /// Engine with every rule except the default-deny category.
61    permitted_engine: GovernanceEngine,
62    /// Rule ids evaluated when no permit matched (for the audit record).
63    default_rule_ids: Vec<String>,
64    permits: Vec<WorkloadPermitRule>,
65    chain: Option<Arc<ChainManager>>,
66    revocations: Option<Arc<RevocationList>>,
67    /// Set by [`WorkloadGate::exempt`]: why there is no revocation list.
68    exempt_reason: Option<&'static str>,
69    /// Project this gate decides for, overriding the kernel's instance
70    /// attestation (tests; a kernel serves one project, set at boot).
71    attestation: Option<ProjectAttestation>,
72}
73
74impl WorkloadGate {
75    /// Gate with the shipped [`default_rules`] and no permits (denies all),
76    /// checking `revocations` on every request. The list is required: a gate
77    /// built without one never denies a revoked package. A gate that
78    /// genuinely has no subject list says why with [`Self::exempt`].
79    pub fn new(
80        risk_threshold: f64,
81        human_approval: bool,
82        revocations: Arc<RevocationList>,
83    ) -> Self {
84        Self::with_rules(risk_threshold, human_approval, default_rules(), revocations)
85    }
86
87    /// [`Self::new`] without a revocation list, for a gate whose workloads
88    /// are not revoked through the subject list (the project supervisor:
89    /// project certificates revoke through the project identity record) and
90    /// for tests. `why` is kept and shown by [`Self::exempt_reason`]; the
91    /// population test allows this only in a short list of files.
92    pub fn exempt(risk_threshold: f64, human_approval: bool, why: &'static str) -> Self {
93        let mut g = Self::build(risk_threshold, human_approval, default_rules(), None);
94        g.exempt_reason = Some(why);
95        g
96    }
97
98    /// Gate over a distributed rule set (e.g. `RuleDistribution::active_rules`)
99    /// checking `revocations`.
100    ///
101    /// Only rules whose selectors can match `workload.*` actions matter; the
102    /// rest are carried but never match.
103    pub fn with_rules(
104        risk_threshold: f64,
105        human_approval: bool,
106        rules: Vec<GovernanceRule>,
107        revocations: Arc<RevocationList>,
108    ) -> Self {
109        Self::build(risk_threshold, human_approval, rules, Some(revocations))
110    }
111
112    /// Why this gate does not check the subject revocation list, if it does not.
113    pub fn exempt_reason(&self) -> Option<&'static str> {
114        self.exempt_reason
115    }
116
117    fn build(
118        risk_threshold: f64,
119        human_approval: bool,
120        rules: Vec<GovernanceRule>,
121        revocations: Option<Arc<RevocationList>>,
122    ) -> Self {
123        let mut permitted_engine = GovernanceEngine::new(risk_threshold, human_approval);
124        let mut default_rule_ids = Vec::new();
125        for rule in rules {
126            if rule.sop_category.as_deref() == Some(DEFAULT_DENY_CATEGORY) {
127                if rule.active {
128                    default_rule_ids.push(rule.id.clone());
129                }
130            } else {
131                permitted_engine.add_rule(rule);
132            }
133        }
134        if default_rule_ids.is_empty() {
135            default_rule_ids.push(format!("{DEFAULT_DENY_RULE_ID} (built-in)"));
136        }
137        Self {
138            permitted_engine,
139            default_rule_ids,
140            permits: Vec::new(),
141            chain: None,
142            revocations,
143            exempt_reason: None,
144            attestation: None,
145        }
146    }
147
148    /// Add a permit rule after validating it.
149    pub fn with_permit(mut self, rule: WorkloadPermitRule) -> Result<Self, String> {
150        rule.validate()?;
151        if self.permits.iter().any(|p| p.id == rule.id) {
152            return Err(format!("duplicate permit rule id '{}'", rule.id));
153        }
154        self.permits.push(rule);
155        Ok(self)
156    }
157
158    /// Attach a chain manager for audit events.
159    pub fn with_chain(mut self, cm: Arc<ChainManager>) -> Self {
160        self.chain = Some(cm);
161        self
162    }
163
164    /// Replace the revocation list checked on every request (an exempt gate
165    /// becomes one that checks it).
166    pub fn with_revocations(mut self, list: Arc<RevocationList>) -> Self {
167        self.revocations = Some(list);
168        self.exempt_reason = None;
169        self
170    }
171
172    /// Decide for the verified project `att` instead of the kernel's
173    /// instance attestation. Permit rules with `projects` match on it.
174    pub fn with_attestation(mut self, att: ProjectAttestation) -> Self {
175        self.attestation = Some(att);
176        self
177    }
178
179    /// The verified project this gate decides for: its own attestation, else
180    /// the kernel's (never anything from the request context).
181    fn project(&self) -> Option<(ProjectAttestation, Option<&'static str>)> {
182        match (
183            &self.attestation,
184            crate::governance_project::instance_project(),
185        ) {
186            (Some(a), inst) => Some((a.clone(), inst.map(|(_, i)| i))),
187            (None, Some((a, i))) => Some((a.clone(), Some(i))),
188            (None, None) => None,
189        }
190    }
191
192    /// Permit rules in evaluation order.
193    pub fn permits(&self) -> &[WorkloadPermitRule] {
194        &self.permits
195    }
196
197    /// The denial for an action that is not a teardown action.
198    pub(crate) fn not_teardown(action: &str) -> Option<GateDecision> {
199        (!TEARDOWN_ACTIONS.contains(&action)).then(|| {
200            Self::deny(format!(
201                "'{action}' is not a teardown action ({})",
202                TEARDOWN_ACTIONS.join(", ")
203            ))
204        })
205    }
206
207    fn deny(reason: impl Into<String>) -> GateDecision {
208        GateDecision::Deny {
209            reason: reason.into(),
210            receipt: None,
211        }
212    }
213
214    fn record(&self, kind: &str, payload: serde_json::Value) {
215        if let Some(cm) = &self.chain {
216            cm.append(CHAIN_SOURCE, kind, Some(payload));
217        }
218    }
219
220    fn decide(&self, agent_id: &str, action: &str, context: &serde_json::Value) -> Outcome {
221        self.decide_with(agent_id, action, context, false)
222    }
223
224    /// [`Self::decide`]; with `skip_revocation` the revocation list is not
225    /// consulted (teardown only: see [`GateBackend::check_teardown`]).
226    fn decide_with(
227        &self,
228        agent_id: &str,
229        action: &str,
230        context: &serde_json::Value,
231        skip_revocation: bool,
232    ) -> Outcome {
233        let mut out = Outcome::new();
234        let req = match WorkloadRequest::from_context(context) {
235            Ok(r) => r,
236            Err(e) => {
237                out.decision = Self::deny(format!("invalid workload context: {e}"));
238                return out;
239            }
240        };
241        out.workload = serde_json::to_value(&req.effect).ok();
242        let vector = req.effect.to_effect_vector();
243        out.effect = serde_json::to_value(&vector).ok();
244
245        if let Some(list) = self.revocations.as_ref().filter(|_| !skip_revocation) {
246            if let Some(err) = list.subjects_error() {
247                out.decision =
248                    Self::deny(format!("revocation list unreadable (fail closed): {err}"));
249                return out;
250            }
251            // A request that names nothing cannot be checked, so it cannot be
252            // trusted: without this a caller (or a builder that lost the
253            // refs) could omit all three and carry only a `package_trust`
254            // claim past the list. Teardown actions are exempt: a stop or
255            // unload names the placed instance, not a package.
256            if NEEDS_REFS.contains(&action)
257                && req.refs.package_id.is_none()
258                && req.refs.signer_keys.is_empty()
259                && req.refs.artifact_hashes.is_empty()
260            {
261                out.decision = Self::deny(format!(
262                    "'{action}' names no package, signer key or artifact hash, so it cannot be \
263                     checked against the revocation list (fail closed)"
264                ));
265                return out;
266            }
267            let hit = list.first_revoked(
268                req.refs.package_id.as_deref(),
269                &req.refs.signer_keys,
270                &req.refs.artifact_hashes,
271            );
272            if let Some(s) = hit {
273                out.decision =
274                    Self::deny(format!("{} '{}' is revoked: {}", s.kind, s.id, s.reason));
275                out.revoked = serde_json::to_value(&s).ok();
276                return out;
277            }
278        }
279
280        if req.effect.secrets && req.effect.node_tier < NodeTrustTier::Pinned {
281            out.decision =
282                Self::deny("workloads carrying secrets require a pinned node (ADR-099 s8.3)");
283            return out;
284        }
285
286        let project = self.project();
287        let project_id = project.as_ref().map(|(a, _)| a.project_id());
288        let Some(permit) = self
289            .permits
290            .iter()
291            .find(|p| p.matches_for(action, &req.effect, project_id, Some(agent_id)))
292        else {
293            out.evaluated_rules = self.default_rule_ids.clone();
294            out.decision = Self::deny(format!(
295                "default deny: no workload permit rule matches '{action}' for kind '{}' (rule {})",
296                req.effect.kind,
297                self.default_rule_ids.join(", ")
298            ));
299            return out;
300        };
301        out.permit_rule = Some(permit.id.clone());
302
303        let request = GovernanceRequest {
304            agent_id: agent_id.to_owned(),
305            action: action.to_owned(),
306            effect: vector,
307            context: req.effect.context_map(),
308            node_id: None,
309            principal: Some(GatePrincipal::agent(agent_id)),
310        }
311        // ADR-103 A6: the project comes from the verified attestation, so
312        // permit rules and the audit principal can match on it.
313        .attributed_with(
314            project.as_ref().map(|(a, _)| a),
315            project.as_ref().and_then(|(_, i)| *i),
316        );
317        let result = self.permitted_engine.evaluate(&request);
318        out.evaluated_rules = result.evaluated_rules;
319        out.threshold_exceeded = result.threshold_exceeded;
320        out.decision = match result.decision {
321            GovernanceDecision::Permit | GovernanceDecision::PermitWithWarning(_) => {
322                GateDecision::Permit { token: None }
323            }
324            GovernanceDecision::EscalateToHuman(reason) => GateDecision::Defer { reason },
325            GovernanceDecision::Deny(reason) => Self::deny(reason),
326        };
327        out
328    }
329}
330
331/// Actions that carry a package and so must name it (see `decide`).
332const NEEDS_REFS: &[&str] = &[
333    "workload.install",
334    "workload.place",
335    "workload.load",
336    "workload.start",
337    "workload.migrate",
338];
339
340/// Actions [`GateBackend::check_teardown`] accepts: stop, unload, and
341/// re-adoption of a placed instance (gated as `workload.load`).
342const TEARDOWN_ACTIONS: &[&str] = &["workload.stop", "workload.unload", "workload.load"];
343
344/// Intermediate result, turned into the chain payload.
345struct Outcome {
346    decision: GateDecision,
347    workload: Option<serde_json::Value>,
348    effect: Option<serde_json::Value>,
349    permit_rule: Option<String>,
350    evaluated_rules: Vec<String>,
351    threshold_exceeded: bool,
352    revoked: Option<serde_json::Value>,
353}
354
355impl Outcome {
356    fn new() -> Self {
357        Self {
358            decision: GateDecision::Deny {
359                reason: "undecided".into(),
360                receipt: None,
361            },
362            workload: None,
363            effect: None,
364            permit_rule: None,
365            evaluated_rules: Vec::new(),
366            threshold_exceeded: false,
367            revoked: None,
368        }
369    }
370}
371
372fn decision_parts(d: &GateDecision) -> (&'static str, Option<&str>) {
373    match d {
374        GateDecision::Permit { .. } => ("permit", None),
375        GateDecision::Defer { reason } => ("defer", Some(reason)),
376        GateDecision::Deny { reason, .. } => ("deny", Some(reason)),
377    }
378}
379
380impl GateBackend for WorkloadGate {
381    fn check(&self, agent_id: &str, action: &str, context: &serde_json::Value) -> GateDecision {
382        if !action.starts_with(WORKLOAD_ACTION_PREFIX) {
383            // Not ours: refuse rather than silently permit. Not chained,
384            // because the workload audit trail covers workload.* only.
385            return Self::deny(format!(
386                "WorkloadGate only governs workload.* actions, got '{action}'"
387            ));
388        }
389        if !is_governed_action(action) {
390            let reason = format!(
391                "unknown workload action '{action}'; governed actions: {}",
392                GOVERNED_ACTIONS.join(", ")
393            );
394            self.record(
395                chain::EVENT_KIND_WORKLOAD_REFUSE,
396                serde_json::json!({
397                    "decision": "deny",
398                    "agent_id": agent_id,
399                    "action": action,
400                    "reason": reason,
401                }),
402            );
403            return Self::deny(reason);
404        }
405
406        let out = self.decide(agent_id, action, context);
407        let (decision, reason) = decision_parts(&out.decision);
408        let kind = event_kind_for(action).unwrap_or(chain::EVENT_KIND_WORKLOAD_REFUSE);
409        self.record(
410            kind,
411            serde_json::json!({
412                "decision": decision,
413                "reason": reason,
414                "agent_id": agent_id,
415                "action": action,
416                "workload": out.workload,
417                "effect": out.effect,
418                "permit_rule": out.permit_rule,
419                "evaluated_rules": out.evaluated_rules,
420                "threshold_exceeded": out.threshold_exceeded,
421                "revoked": out.revoked,
422            }),
423        );
424        out.decision
425    }
426
427    fn check_teardown(
428        &self,
429        agent_id: &str,
430        action: &str,
431        context: &serde_json::Value,
432    ) -> GateDecision {
433        // Only taking something down (or re-attaching what is already
434        // placed, gated as a load) may use this path; anything else could
435        // be used to dodge the node-tier rules.
436        if let Some(d) = Self::not_teardown(action) {
437            debug_assert!(false, "check_teardown called with {action}");
438            return d;
439        }
440        let first = self.check(agent_id, action, context);
441        let GateDecision::Deny { reason, .. } = &first else {
442            return first;
443        };
444        // Would the same request pass on a node of the highest tier, with the
445        // revocation list set aside? Then the denial was the node tier, a
446        // revocation, or both, and none of those blocks taking something
447        // down: stopping a revoked package is the point of revoking it.
448        // Default deny, rule and threshold denials do not depend on either
449        // and stay denials.
450        let mut raised = context.clone();
451        let Some(w) = raised.get_mut("workload").and_then(|w| w.as_object_mut()) else {
452            return first;
453        };
454        w.insert("node_tier".into(), serde_json::json!(NodeTrustTier::Pinned));
455        if !self.decide_with(agent_id, action, &raised, true).decision.is_permit() {
456            return first;
457        }
458        let tier_waived = !self.decide_with(agent_id, action, context, true).decision.is_permit();
459        let revoked = self.decide(agent_id, action, context).revoked;
460        let kind = event_kind_for(action).unwrap_or(chain::EVENT_KIND_WORKLOAD_REFUSE);
461        self.record(
462            kind,
463            serde_json::json!({
464                "decision": "permit", "agent_id": agent_id, "action": action,
465                "teardown_node_tier_waived": tier_waived,
466                "teardown_revocation_waived": revoked,
467                "waived_denial": reason,
468                "node_tier": context.pointer("/workload/node_tier"),
469            }),
470        );
471        GateDecision::Permit { token: None }
472    }
473}
474
475/// Chain every revocation and un-revocation `list` takes from now on, from
476/// any caller (the operator verb, a mesh notice, kernel code), as
477/// `workload.revoke` / `workload.unrevoke` events from source
478/// [`CHAIN_SOURCE`]. First call wins; returns whether this one did.
479pub fn chain_revocations(list: &RevocationList, chain: Arc<ChainManager>) -> bool {
480    list.set_audit(Arc::new(move |kind, payload| {
481        chain.append(CHAIN_SOURCE, kind, Some(payload));
482    }))
483}
484
485/// Revoke a subject and chain a `workload.revoke` event when newly added.
486///
487/// Returns `Ok(true)` if newly revoked, `Ok(false)` if it already was. The
488/// event is chained even when persisting the list fails (the entry holds in
489/// memory, the event says `persisted: false`, and the error is returned).
490/// With `chain` `None` the list's own audit sink ([`chain_revocations`])
491/// records it, if one is installed.
492pub fn revoke_and_record(
493    list: &RevocationList,
494    chain: Option<&ChainManager>,
495    kind: RevocationKind,
496    id: &str,
497    reason: &str,
498    revoked_by: &str,
499) -> Result<bool, crate::revocation::RevocationError> {
500    match chain {
501        Some(cm) => list.revoke_audited(kind, id, reason, revoked_by, Some(&|k, p| {
502            cm.append(CHAIN_SOURCE, k, Some(p));
503        })),
504        None => list.revoke_subject_by(kind, id, reason, revoked_by),
505    }
506}
507
508/// Lift a revocation and chain a `workload.unrevoke` event when one was
509/// removed. Same chain rules as [`revoke_and_record`].
510pub fn unrevoke_and_record(
511    list: &RevocationList,
512    chain: Option<&ChainManager>,
513    kind: RevocationKind,
514    id: &str,
515    unrevoked_by: &str,
516) -> Result<bool, crate::revocation::RevocationError> {
517    match chain {
518        Some(cm) => list.unrevoke_audited(kind, id, unrevoked_by, Some(&|k, p| {
519            cm.append(CHAIN_SOURCE, k, Some(p));
520        })),
521        None => list.unrevoke_subject_by(kind, id, unrevoked_by),
522    }
523}