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:
parent
323617d6cc
commit
62b00ad84c
5 changed files with 357 additions and 3 deletions
131
org-ops-core/src/delegation.rs
Normal file
131
org-ops-core/src/delegation.rs
Normal 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 { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
181
org-ops-core/src/preflight.rs
Normal file
181
org-ops-core/src/preflight.rs
Normal 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 { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue