1use 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
35pub const CHAIN_SOURCE: &str = "workload";
37
38pub const DEFAULT_THRESHOLD: f64 = 0.8;
40
41pub 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
58pub struct WorkloadGate {
60 permitted_engine: GovernanceEngine,
62 default_rule_ids: Vec<String>,
64 permits: Vec<WorkloadPermitRule>,
65 chain: Option<Arc<ChainManager>>,
66 revocations: Option<Arc<RevocationList>>,
67 exempt_reason: Option<&'static str>,
69 attestation: Option<ProjectAttestation>,
72}
73
74impl WorkloadGate {
75 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 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 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 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 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 pub fn with_chain(mut self, cm: Arc<ChainManager>) -> Self {
160 self.chain = Some(cm);
161 self
162 }
163
164 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 pub fn with_attestation(mut self, att: ProjectAttestation) -> Self {
175 self.attestation = Some(att);
176 self
177 }
178
179 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 pub fn permits(&self) -> &[WorkloadPermitRule] {
194 &self.permits
195 }
196
197 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 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 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 .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
331const NEEDS_REFS: &[&str] = &[
333 "workload.install",
334 "workload.place",
335 "workload.load",
336 "workload.start",
337 "workload.migrate",
338];
339
340const TEARDOWN_ACTIONS: &[&str] = &["workload.stop", "workload.unload", "workload.load"];
343
344struct 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 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 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 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
475pub 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
485pub 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
508pub 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}