feat(org-ops): manifest loader with ShellClass filtering and CID verification
GSH manifest loader reads verified entries from the manifest-{name}
ConfigMap written by the substrate-operator reconciler:
- Filters entries by session ShellClass (System hidden in App shells)
- Filters delegation-context binaries in non-delegation sessions
- compute_file_cid() verifies on-disk binary hashes against CIDs
- verify_binary_hashes() detects tampering and missing binaries
- ManifestLoadResult reports loaded/excluded with reasons
10 unit tests covering:
- Application shell excludes system binaries
- System shell loads all non-delegation entries
- Delegation-permitted sessions load delegation binaries
- Empty/invalid manifest handling
- File CID computation (SHA-256, verified against known hash)
- Hash mismatch and missing file detection
Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
parent
c68456d745
commit
5c92c027fc
2 changed files with 300 additions and 0 deletions
|
|
@ -13,6 +13,7 @@ pub mod chronicle_client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod delegation;
|
pub mod delegation;
|
||||||
pub mod git_hash;
|
pub mod git_hash;
|
||||||
|
pub mod manifest_loader;
|
||||||
pub mod test_evidence;
|
pub mod test_evidence;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
pub mod git_commands;
|
pub mod git_commands;
|
||||||
|
|
|
||||||
299
org-ops-core/src/manifest_loader.rs
Normal file
299
org-ops-core/src/manifest_loader.rs
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
// Copyright 2026 Guildhouse Dev
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
//! Manifest loader — reads verified manifest entries and filters them
|
||||||
|
//! by the session's ShellClass and delegation scope.
|
||||||
|
//!
|
||||||
|
//! Called at GSH session start to determine which binaries are authorized.
|
||||||
|
//! Reads the `manifest-{name}` ConfigMap output written by the
|
||||||
|
//! substrate-operator's manifest reconciler.
|
||||||
|
|
||||||
|
use crate::session::SessionContext;
|
||||||
|
use crate::shell_class::ShellClass;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// A verified manifest entry (matches the reconciler's output format).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VerifiedEntry {
|
||||||
|
pub binary_name: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub shell_class: String,
|
||||||
|
pub delegation_context: bool,
|
||||||
|
pub capability_mask: i64,
|
||||||
|
pub tier: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A binary that was loaded (authorized for this session).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LoadedBinary {
|
||||||
|
pub name: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub shell_class: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A binary that was excluded (not authorized for this session).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExcludedBinary {
|
||||||
|
pub name: String,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of loading and filtering a manifest for a session.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ManifestLoadResult {
|
||||||
|
pub loaded: Vec<LoadedBinary>,
|
||||||
|
pub excluded: Vec<ExcludedBinary>,
|
||||||
|
pub manifest_cid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and filter manifest entries for the current session.
|
||||||
|
///
|
||||||
|
/// Reads the manifest JSON (from ConfigMap mount or API), filters
|
||||||
|
/// entries by the session's ShellClass and delegation scope, and
|
||||||
|
/// optionally verifies on-disk binary hashes.
|
||||||
|
pub fn load_manifest(
|
||||||
|
ctx: &SessionContext,
|
||||||
|
manifest_json: &str,
|
||||||
|
) -> Result<ManifestLoadResult, String> {
|
||||||
|
let entries: Vec<VerifiedEntry> =
|
||||||
|
serde_json::from_str(manifest_json).map_err(|e| format!("manifest parse error: {e}"))?;
|
||||||
|
|
||||||
|
let manifest_cid = format!(
|
||||||
|
"sha256:{}",
|
||||||
|
hex::encode(Sha256::digest(manifest_json.as_bytes()))
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut loaded = Vec::new();
|
||||||
|
let mut excluded = Vec::new();
|
||||||
|
|
||||||
|
for entry in &entries {
|
||||||
|
// Filter by ShellClass
|
||||||
|
let entry_class = parse_shell_class(&entry.shell_class);
|
||||||
|
if !ctx.shell_class.satisfies(entry_class) {
|
||||||
|
excluded.push(ExcludedBinary {
|
||||||
|
name: entry.binary_name.clone(),
|
||||||
|
reason: format!("requires {} shell", entry.shell_class),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter delegation-context binaries in non-delegation sessions
|
||||||
|
if entry.delegation_context && !ctx.delegation_scope.permitted {
|
||||||
|
excluded.push(ExcludedBinary {
|
||||||
|
name: entry.binary_name.clone(),
|
||||||
|
reason: "requires delegation authority".into(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded.push(LoadedBinary {
|
||||||
|
name: entry.binary_name.clone(),
|
||||||
|
cid: entry.cid.clone(),
|
||||||
|
shell_class: entry.shell_class.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ManifestLoadResult {
|
||||||
|
loaded,
|
||||||
|
excluded,
|
||||||
|
manifest_cid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a CID (SHA-256) for a file on disk.
|
||||||
|
///
|
||||||
|
/// Matches the `compute_cid()` format in gsap_client.rs.
|
||||||
|
pub fn compute_file_cid(path: &std::path::Path) -> Result<String, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||||
|
Ok(format!("sha256:{}", hex::encode(Sha256::digest(&bytes))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that on-disk binaries match their manifest CIDs.
|
||||||
|
///
|
||||||
|
/// Returns a list of mismatched entries. Empty = all verified.
|
||||||
|
pub fn verify_binary_hashes(
|
||||||
|
loaded: &[LoadedBinary],
|
||||||
|
bin_dir: &std::path::Path,
|
||||||
|
) -> Vec<ExcludedBinary> {
|
||||||
|
let mut mismatches = Vec::new();
|
||||||
|
for binary in loaded {
|
||||||
|
let path = bin_dir.join(&binary.name);
|
||||||
|
match compute_file_cid(&path) {
|
||||||
|
Ok(on_disk_cid) => {
|
||||||
|
if on_disk_cid != binary.cid {
|
||||||
|
mismatches.push(ExcludedBinary {
|
||||||
|
name: binary.name.clone(),
|
||||||
|
reason: format!(
|
||||||
|
"hash mismatch: expected {}, found {}",
|
||||||
|
binary.cid, on_disk_cid
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(reason) => {
|
||||||
|
mismatches.push(ExcludedBinary {
|
||||||
|
name: binary.name.clone(),
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mismatches
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_shell_class(s: &str) -> ShellClass {
|
||||||
|
match s {
|
||||||
|
"system" => ShellClass::System,
|
||||||
|
_ => ShellClass::Application,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::delegation::DelegationScope;
|
||||||
|
|
||||||
|
fn ctx(shell_class: ShellClass, delegation_permitted: bool) -> SessionContext {
|
||||||
|
SessionContext {
|
||||||
|
org_name: "test".into(),
|
||||||
|
trust_domain: "test.io".into(),
|
||||||
|
bascule_endpoint: "localhost".into(),
|
||||||
|
shell_class,
|
||||||
|
posture_level: 5,
|
||||||
|
delegation_scope: DelegationScope {
|
||||||
|
permitted: delegation_permitted,
|
||||||
|
target_hosts: vec![],
|
||||||
|
max_delegated_class: ShellClass::System,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_json() -> String {
|
||||||
|
serde_json::to_string(&vec![
|
||||||
|
VerifiedEntry {
|
||||||
|
binary_name: "kubectl".into(),
|
||||||
|
cid: "sha256:aaa".into(),
|
||||||
|
shell_class: "application".into(),
|
||||||
|
delegation_context: false,
|
||||||
|
capability_mask: 1,
|
||||||
|
tier: "a".into(),
|
||||||
|
},
|
||||||
|
VerifiedEntry {
|
||||||
|
binary_name: "firmware-update".into(),
|
||||||
|
cid: "sha256:bbb".into(),
|
||||||
|
shell_class: "system".into(),
|
||||||
|
delegation_context: false,
|
||||||
|
capability_mask: 7,
|
||||||
|
tier: "c".into(),
|
||||||
|
},
|
||||||
|
VerifiedEntry {
|
||||||
|
binary_name: "ansible-playbook".into(),
|
||||||
|
cid: "sha256:ccc".into(),
|
||||||
|
shell_class: "application".into(),
|
||||||
|
delegation_context: true,
|
||||||
|
capability_mask: 3,
|
||||||
|
tier: "a".into(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_shell_excludes_system_binaries() {
|
||||||
|
let result = load_manifest(&ctx(ShellClass::Application, false), &manifest_json()).unwrap();
|
||||||
|
assert_eq!(result.loaded.len(), 1); // only kubectl (ansible needs delegation)
|
||||||
|
assert_eq!(result.loaded[0].name, "kubectl");
|
||||||
|
assert_eq!(result.excluded.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_shell_loads_all_non_delegation() {
|
||||||
|
let result = load_manifest(&ctx(ShellClass::System, false), &manifest_json()).unwrap();
|
||||||
|
// kubectl + firmware-update loaded; ansible excluded (delegation_context)
|
||||||
|
assert_eq!(result.loaded.len(), 2);
|
||||||
|
assert_eq!(result.excluded.len(), 1);
|
||||||
|
assert_eq!(result.excluded[0].name, "ansible-playbook");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delegation_permitted_loads_delegation_binaries() {
|
||||||
|
let result = load_manifest(&ctx(ShellClass::Application, true), &manifest_json()).unwrap();
|
||||||
|
// kubectl + ansible loaded; firmware-update excluded (system)
|
||||||
|
assert_eq!(result.loaded.len(), 2);
|
||||||
|
let names: Vec<&str> = result.loaded.iter().map(|b| b.name.as_str()).collect();
|
||||||
|
assert!(names.contains(&"kubectl"));
|
||||||
|
assert!(names.contains(&"ansible-playbook"));
|
||||||
|
assert_eq!(result.excluded.len(), 1);
|
||||||
|
assert_eq!(result.excluded[0].name, "firmware-update");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_shell_with_delegation_loads_all() {
|
||||||
|
let result = load_manifest(&ctx(ShellClass::System, true), &manifest_json()).unwrap();
|
||||||
|
assert_eq!(result.loaded.len(), 3);
|
||||||
|
assert_eq!(result.excluded.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_manifest_returns_empty() {
|
||||||
|
let result = load_manifest(&ctx(ShellClass::Application, false), "[]").unwrap();
|
||||||
|
assert_eq!(result.loaded.len(), 0);
|
||||||
|
assert_eq!(result.excluded.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_json_returns_error() {
|
||||||
|
let result = load_manifest(&ctx(ShellClass::Application, false), "not json");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_cid_is_computed() {
|
||||||
|
let result = load_manifest(&ctx(ShellClass::System, true), &manifest_json()).unwrap();
|
||||||
|
assert!(result.manifest_cid.starts_with("sha256:"));
|
||||||
|
assert_eq!(result.manifest_cid.len(), 7 + 64); // "sha256:" + 64 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_cid_computation() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test-binary");
|
||||||
|
std::fs::write(&path, b"hello world").unwrap();
|
||||||
|
let cid = compute_file_cid(&path).unwrap();
|
||||||
|
assert!(cid.starts_with("sha256:"));
|
||||||
|
// SHA-256 of "hello world" is known
|
||||||
|
assert_eq!(
|
||||||
|
cid,
|
||||||
|
"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_hashes_detects_mismatch() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test-bin");
|
||||||
|
std::fs::write(&path, b"actual content").unwrap();
|
||||||
|
|
||||||
|
let loaded = vec![LoadedBinary {
|
||||||
|
name: "test-bin".into(),
|
||||||
|
cid: "sha256:wrong".into(),
|
||||||
|
shell_class: "application".into(),
|
||||||
|
}];
|
||||||
|
let mismatches = verify_binary_hashes(&loaded, dir.path());
|
||||||
|
assert_eq!(mismatches.len(), 1);
|
||||||
|
assert!(mismatches[0].reason.contains("hash mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_hashes_missing_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let loaded = vec![LoadedBinary {
|
||||||
|
name: "nonexistent".into(),
|
||||||
|
cid: "sha256:abc".into(),
|
||||||
|
shell_class: "application".into(),
|
||||||
|
}];
|
||||||
|
let mismatches = verify_binary_hashes(&loaded, dir.path());
|
||||||
|
assert_eq!(mismatches.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue