From 62b00ad84c8e29b9332357c2bead640b25e0123741602e4ce0a63e80d842d9e4 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Wed, 15 Apr 2026 15:17:48 -0400 Subject: [PATCH] feat(org-ops): worker pre-flight with delegation enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add worker_preflight() check at dispatch time for commands that target remote hosts. Enforces three conditions: 1. Session has delegation authority 2. Target host is in delegation scope 3. Target host posture satisfies required shell class OrgCommands trait extended with target_host() method (default: None for local commands). SessionContext enriched with delegation_scope. Lightweight DelegationScope duplicate avoids bascule-core dep chain. Target posture reader stubbed — requires gateway posture query API (tracked as follow-up). Fail-closed: unknown delegation -> denied, unknown posture -> denied. 11 unit tests for delegation and preflight. Signed-off-by: Tyler King Signed-off-by: Tyler J King --- org-ops-core/src/delegation.rs | 131 ++++++++++++++++++++++++ org-ops-core/src/lib.rs | 32 ++++++ org-ops-core/src/preflight.rs | 181 +++++++++++++++++++++++++++++++++ org-ops-core/src/session.rs | 6 +- org-ops-core/src/traits.rs | 10 ++ 5 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 org-ops-core/src/delegation.rs create mode 100644 org-ops-core/src/preflight.rs diff --git a/org-ops-core/src/delegation.rs b/org-ops-core/src/delegation.rs new file mode 100644 index 0000000..b88f664 --- /dev/null +++ b/org-ops-core/src/delegation.rs @@ -0,0 +1,131 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + +//! Lightweight delegation scope for org-ops dispatch enforcement. +//! +//! Mirrors the essential fields from `bascule_core::DelegationScope` +//! without pulling in the full bascule-core dependency tree. + +use crate::shell_class::ShellClass; + +/// Delegation authority for a session. +#[derive(Debug, Clone)] +pub struct DelegationScope { + /// Whether delegation is permitted. + pub permitted: bool, + /// Specific target hosts allowed (empty = no targets). + pub target_hosts: Vec, + /// Maximum shell class that can be delegated. + pub max_delegated_class: ShellClass, +} + +impl Default for DelegationScope { + fn default() -> Self { + Self { + permitted: false, + target_hosts: vec![], + max_delegated_class: ShellClass::Application, + } + } +} + +/// Result of a delegation check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DelegationDecision { + Permitted, + Denied { reason: String }, +} + +impl DelegationScope { + /// Check whether delegation is permitted for a target and class. + pub fn permits(&self, target_host: &str, required_class: ShellClass) -> DelegationDecision { + if !self.permitted { + return DelegationDecision::Denied { + reason: "session does not have delegation authority".into(), + }; + } + + if !self.target_hosts.is_empty() + && !self.target_hosts.iter().any(|h| h == target_host) + { + return DelegationDecision::Denied { + reason: format!("target '{target_host}' not in delegation scope"), + }; + } + + if !self.max_delegated_class.satisfies(required_class) { + return DelegationDecision::Denied { + reason: format!( + "delegation permits {}, but {} required", + self.max_delegated_class, required_class + ), + }; + } + + DelegationDecision::Permitted + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_denies() { + let scope = DelegationScope::default(); + let d = scope.permits("any", ShellClass::Application); + assert!(matches!(d, DelegationDecision::Denied { .. })); + } + + #[test] + fn permitted_with_matching_host() { + let scope = DelegationScope { + permitted: true, + target_hosts: vec!["worker-1".into()], + max_delegated_class: ShellClass::System, + }; + assert_eq!( + scope.permits("worker-1", ShellClass::System), + DelegationDecision::Permitted + ); + } + + #[test] + fn permitted_host_not_in_scope() { + let scope = DelegationScope { + permitted: true, + target_hosts: vec!["worker-1".into()], + max_delegated_class: ShellClass::System, + }; + assert!(matches!( + scope.permits("worker-99", ShellClass::Application), + DelegationDecision::Denied { .. } + )); + } + + #[test] + fn empty_hosts_allows_any_when_permitted() { + let scope = DelegationScope { + permitted: true, + target_hosts: vec![], // empty = open delegation + max_delegated_class: ShellClass::System, + }; + assert_eq!( + scope.permits("any-host", ShellClass::System), + DelegationDecision::Permitted + ); + } + + #[test] + fn class_ceiling_enforced() { + let scope = DelegationScope { + permitted: true, + target_hosts: vec![], + max_delegated_class: ShellClass::Application, + }; + assert!(matches!( + scope.permits("host", ShellClass::System), + DelegationDecision::Denied { .. } + )); + } +} diff --git a/org-ops-core/src/lib.rs b/org-ops-core/src/lib.rs index b42644d..992aad1 100644 --- a/org-ops-core/src/lib.rs +++ b/org-ops-core/src/lib.rs @@ -11,12 +11,14 @@ pub mod apply_gate; pub mod auth_commands; pub mod chronicle_client; pub mod config; +pub mod delegation; pub mod git_hash; pub mod test_evidence; pub mod display; pub mod git_commands; pub mod pkce; pub mod playbook_commands; +pub mod preflight; pub mod score_fetcher; pub mod session; pub mod shell_class; @@ -92,9 +94,11 @@ impl OrgOps { bascule_endpoint: self.config.bascule_endpoint.clone(), shell_class: shell_class::ShellClass::default(), posture_level: 5, // Normal — default for local/unconnected + delegation_scope: delegation::DelegationScope::default(), }; for cmd in &self.commands { if cmd.handles(name) { + // Local shell class enforcement let required = cmd.required_shell_class(); if !ctx.shell_class.satisfies(required) { anyhow::bail!( @@ -106,6 +110,34 @@ impl OrgOps { ctx.shell_class ); } + + // Remote delegation pre-flight + if let Some(target) = cmd.target_host(sub) { + // Target posture not yet queryable — stub returns + // None which fails closed in the preflight check. + // TODO: Wire posture query via gateway API or ConfigMap. + let target_posture: Option = None; + let result = preflight::worker_preflight( + &ctx, + &target, + cmd.required_shell_class(), + target_posture, + ); + match result { + preflight::PreflightResult::Cleared { .. } => {} + preflight::PreflightResult::Denied { + reason, hint, .. + } => { + anyhow::bail!( + "Delegation to '{}' denied: {}. {}", + target, + reason, + hint + ); + } + } + } + return cmd.handle(name, sub, &ctx); } } diff --git a/org-ops-core/src/preflight.rs b/org-ops-core/src/preflight.rs new file mode 100644 index 0000000..abbc35b --- /dev/null +++ b/org-ops-core/src/preflight.rs @@ -0,0 +1,181 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + +//! Worker pre-flight — enforcement point for remote command dispatch. +//! +//! Before dispatching a governed operation to a target host, checks: +//! 1. Session has delegation authority +//! 2. Target is in delegation scope +//! 3. Target host posture satisfies the required shell class + +use crate::delegation::DelegationDecision; +use crate::session::SessionContext; +use crate::shell_class::ShellClass; + +/// Result of a worker pre-flight check. +#[derive(Debug)] +pub enum PreflightResult { + /// Dispatch is permitted. + Cleared { + target_host: String, + target_posture_level: u8, + delegated_class: ShellClass, + }, + /// Dispatch is denied. + Denied { + target_host: String, + reason: String, + hint: String, + }, +} + +/// Perform pre-flight checks before dispatching to a target host. +pub fn worker_preflight( + ctx: &SessionContext, + target_host: &str, + required_class: ShellClass, + target_posture_level: Option, +) -> PreflightResult { + // Step 1+2: Check delegation scope + let decision = ctx.delegation_scope.permits(target_host, required_class); + + match decision { + DelegationDecision::Denied { reason } => { + return PreflightResult::Denied { + target_host: target_host.into(), + reason, + hint: "The Accord for this session does not permit delegation \ + to this target. Request a new session with appropriate \ + delegation authority." + .into(), + }; + } + DelegationDecision::Permitted => {} + } + + // Step 3: Check target host posture + let target_posture = match target_posture_level { + Some(level) => level, + None => { + return PreflightResult::Denied { + target_host: target_host.into(), + reason: "target host posture unknown".into(), + hint: "Ensure the target host has a Keylime agent enrolled and \ + the posture evaluator is monitoring it." + .into(), + }; + } + }; + + let target_class = derive_shell_class_from_wire(target_posture, None); + + if !target_class.satisfies(required_class) { + return PreflightResult::Denied { + target_host: target_host.into(), + reason: format!( + "target posture level {} grants {} access, but {} required", + target_posture, target_class, required_class + ), + hint: "The target host's attestation posture is insufficient. \ + Verify Keylime attestation status and wait for recovery." + .into(), + }; + } + + PreflightResult::Cleared { + target_host: target_host.into(), + target_posture_level: target_posture, + delegated_class: required_class, + } +} + +/// Derive ShellClass from a wire posture level. +fn derive_shell_class_from_wire(posture_wire: u8, threshold: Option) -> ShellClass { + let thresh = threshold.unwrap_or(5); // Normal = 5 = System eligible + if posture_wire >= thresh { + ShellClass::System + } else { + ShellClass::Application + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::delegation::DelegationScope; + + fn ctx_with_delegation(delegation: DelegationScope) -> SessionContext { + SessionContext { + org_name: "test".into(), + trust_domain: "test.io".into(), + bascule_endpoint: "localhost".into(), + shell_class: ShellClass::Application, + posture_level: 5, + delegation_scope: delegation, + } + } + + #[test] + fn no_delegation_authority() { + let ctx = ctx_with_delegation(DelegationScope::default()); + let result = worker_preflight(&ctx, "host-1", ShellClass::Application, Some(5)); + assert!(matches!(result, PreflightResult::Denied { .. })); + } + + #[test] + fn delegation_permitted_target_ok_posture_ok() { + let ctx = ctx_with_delegation(DelegationScope { + permitted: true, + target_hosts: vec!["host-1".into()], + max_delegated_class: ShellClass::System, + }); + let result = worker_preflight(&ctx, "host-1", ShellClass::System, Some(5)); + assert!(matches!(result, PreflightResult::Cleared { .. })); + } + + #[test] + fn delegation_permitted_target_not_in_scope() { + let ctx = ctx_with_delegation(DelegationScope { + permitted: true, + target_hosts: vec!["host-1".into()], + max_delegated_class: ShellClass::System, + }); + let result = worker_preflight(&ctx, "host-99", ShellClass::Application, Some(5)); + assert!(matches!(result, PreflightResult::Denied { .. })); + } + + #[test] + fn target_posture_insufficient() { + let ctx = ctx_with_delegation(DelegationScope { + permitted: true, + target_hosts: vec![], + max_delegated_class: ShellClass::System, + }); + let result = worker_preflight(&ctx, "host-1", ShellClass::System, Some(3)); // Restricted + assert!(matches!(result, PreflightResult::Denied { .. })); + } + + #[test] + fn target_posture_unknown_fails_closed() { + let ctx = ctx_with_delegation(DelegationScope { + permitted: true, + target_hosts: vec![], + max_delegated_class: ShellClass::System, + }); + let result = worker_preflight(&ctx, "host-1", ShellClass::System, None); + assert!(matches!(result, PreflightResult::Denied { .. })); + } + + #[test] + fn infrastructure_shell_pattern() { + // Application session + System delegation to attested target + let ctx = ctx_with_delegation(DelegationScope { + permitted: true, + target_hosts: vec!["worker-1".into()], + max_delegated_class: ShellClass::System, + }); + // ctx.shell_class is Application, but delegation allows System to worker-1 + let result = worker_preflight(&ctx, "worker-1", ShellClass::System, Some(5)); + assert!(matches!(result, PreflightResult::Cleared { .. })); + } +} diff --git a/org-ops-core/src/session.rs b/org-ops-core/src/session.rs index b5e9719..f3398bc 100644 --- a/org-ops-core/src/session.rs +++ b/org-ops-core/src/session.rs @@ -1,6 +1,7 @@ // Copyright 2026 Guildhouse Dev // SPDX-License-Identifier: Apache-2.0 +use crate::delegation::DelegationScope; use crate::shell_class::ShellClass; #[derive(Debug, Clone)] @@ -9,10 +10,9 @@ pub struct SessionContext { pub trust_domain: String, pub bascule_endpoint: String, /// Shell class for this session (Application or System). - /// Populated from Bascule SessionScope when connected via gsh. - /// Defaults to Application for local/unconnected usage. pub shell_class: ShellClass, /// Posture level wire value (1-5) at session establishment. - /// Defaults to 5 (Normal) for local/unconnected usage. pub posture_level: u8, + /// Delegation authority for remote dispatch. + pub delegation_scope: DelegationScope, } diff --git a/org-ops-core/src/traits.rs b/org-ops-core/src/traits.rs index bf9f5b0..d1dbc2f 100644 --- a/org-ops-core/src/traits.rs +++ b/org-ops-core/src/traits.rs @@ -20,6 +20,16 @@ pub trait OrgCommands: Send + Sync { fn required_shell_class(&self) -> ShellClass { ShellClass::Application } + + /// If this command dispatches to a remote target, return the target + /// host identifier from the command arguments. + /// + /// Default: `None` — local command, no delegation needed. + /// Override for commands that target remote hosts (e.g., playbook + /// execution on a managed node). + fn target_host(&self, _args: &clap::ArgMatches) -> Option { + None + } } /// Implement this for custom risk scoring.