feat(org-ops): worker pre-flight with delegation enforcement

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 <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
Tyler J King 2026-04-15 15:17:48 -04:00
parent 323617d6cc
commit 62b00ad84c
5 changed files with 357 additions and 3 deletions

View file

@ -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<String>,
/// 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 { .. }
));
}
}

View file

@ -11,12 +11,14 @@ pub mod apply_gate;
pub mod auth_commands; pub mod auth_commands;
pub mod chronicle_client; pub mod chronicle_client;
pub mod config; pub mod config;
pub mod delegation;
pub mod git_hash; pub mod git_hash;
pub mod test_evidence; pub mod test_evidence;
pub mod display; pub mod display;
pub mod git_commands; pub mod git_commands;
pub mod pkce; pub mod pkce;
pub mod playbook_commands; pub mod playbook_commands;
pub mod preflight;
pub mod score_fetcher; pub mod score_fetcher;
pub mod session; pub mod session;
pub mod shell_class; pub mod shell_class;
@ -92,9 +94,11 @@ impl OrgOps {
bascule_endpoint: self.config.bascule_endpoint.clone(), bascule_endpoint: self.config.bascule_endpoint.clone(),
shell_class: shell_class::ShellClass::default(), shell_class: shell_class::ShellClass::default(),
posture_level: 5, // Normal — default for local/unconnected posture_level: 5, // Normal — default for local/unconnected
delegation_scope: delegation::DelegationScope::default(),
}; };
for cmd in &self.commands { for cmd in &self.commands {
if cmd.handles(name) { if cmd.handles(name) {
// Local shell class enforcement
let required = cmd.required_shell_class(); let required = cmd.required_shell_class();
if !ctx.shell_class.satisfies(required) { if !ctx.shell_class.satisfies(required) {
anyhow::bail!( anyhow::bail!(
@ -106,6 +110,34 @@ impl OrgOps {
ctx.shell_class 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<u8> = 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); return cmd.handle(name, sub, &ctx);
} }
} }

View file

@ -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<u8>,
) -> 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<u8>) -> 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 { .. }));
}
}

View file

@ -1,6 +1,7 @@
// Copyright 2026 Guildhouse Dev // Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
use crate::delegation::DelegationScope;
use crate::shell_class::ShellClass; use crate::shell_class::ShellClass;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -9,10 +10,9 @@ pub struct SessionContext {
pub trust_domain: String, pub trust_domain: String,
pub bascule_endpoint: String, pub bascule_endpoint: String,
/// Shell class for this session (Application or System). /// 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, pub shell_class: ShellClass,
/// Posture level wire value (1-5) at session establishment. /// Posture level wire value (1-5) at session establishment.
/// Defaults to 5 (Normal) for local/unconnected usage.
pub posture_level: u8, pub posture_level: u8,
/// Delegation authority for remote dispatch.
pub delegation_scope: DelegationScope,
} }

View file

@ -20,6 +20,16 @@ pub trait OrgCommands: Send + Sync {
fn required_shell_class(&self) -> ShellClass { fn required_shell_class(&self) -> ShellClass {
ShellClass::Application 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<String> {
None
}
} }
/// Implement this for custom risk scoring. /// Implement this for custom risk scoring.