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 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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
// 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement this for custom risk scoring.
|
||||
|
|
|
|||
Loading…
Reference in a new issue