diff --git a/Cargo.lock b/Cargo.lock index 6dedf05..2c4cce5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,9 +1184,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" dependencies = [ "cmov", ] @@ -2340,6 +2340,7 @@ dependencies = [ "sha2 0.10.9", "tempfile", "tokio", + "uuid", ] [[package]] @@ -2354,6 +2355,8 @@ dependencies = [ "ipnet", "omloom-contracts", "prost", + "serde", + "serde_json", "sha2 0.10.9", "tempfile", ] diff --git a/crates/omloom-contracts/build.rs b/crates/omloom-contracts/build.rs index c118b6f..fd5893d 100644 --- a/crates/omloom-contracts/build.rs +++ b/crates/omloom-contracts/build.rs @@ -21,6 +21,7 @@ fn main() -> Result<(), Box> { .boxed(".omloom.workspace.v1.Address.allocation") .boxed(".omloom.workspace.v1.Address.interface") .boxed(".omloom.workspace.v1.Reply.result.detail") + .boxed(".omloom.workspace.v1.Reply.result.vm_placement_review") .boxed(".omloom.workspace.v1.HostImport.report") .file_descriptor_set_path(&descriptor) .compile_protos(&[workspace], &["../../proto"])?; diff --git a/crates/omloom-observe/Cargo.toml b/crates/omloom-observe/Cargo.toml index 2dbd1c6..42bc5f8 100644 --- a/crates/omloom-observe/Cargo.toml +++ b/crates/omloom-observe/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] omloom-contracts = { path = "../omloom-contracts" } prost.workspace = true +uuid.workspace = true serde_json = "1" sha2 = "0.10" tokio = { version = "1", features = ["macros", "rt", "process", "io-util", "time"] } diff --git a/crates/omloom-observe/src/main.rs b/crates/omloom-observe/src/main.rs index 70441fb..56c793b 100644 --- a/crates/omloom-observe/src/main.rs +++ b/crates/omloom-observe/src/main.rs @@ -76,7 +76,15 @@ fn add(report: &mut api::HostReport, value: api::HostFinding) -> Result<()> { async fn collect() -> Result { let mut runner = command::Runner::new(); - let (platform, source, boot, name) = native::identity(&mut runner).await?; + let identity = native::identity(&mut runner).await?; + let native::Identity { + platform, + source, + boot, + name, + hardware, + installation: installation_claim, + } = identity; let observed_at = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| "Clock predates Unix epoch")? @@ -91,10 +99,9 @@ async fn collect() -> Result { observed_at, ..Default::default() }; - add( - &mut report, - finding("hardware", wire::AssetKind::Unspecified, None, &name), - )?; + let mut host = finding("hardware", wire::AssetKind::Unspecified, None, &name); + host.identity.extend(hardware); + add(&mut report, host)?; let mut installation = finding( "installation", wire::AssetKind::SoftwareInstallation, @@ -105,6 +112,7 @@ async fn collect() -> Result { "installation_kind", wire::InstallationKind::OperatingSystem.as_str_name(), )); + installation.identity.push(installation_claim); add(&mut report, installation)?; coverage( &mut report, @@ -120,8 +128,11 @@ async fn collect() -> Result { }); native::collect(&mut runner, &mut report).await?; runtime::collect(&mut runner, &mut report).await?; - let (_, after_source, after_boot, _) = native::identity(&mut runner).await?; - check_identity(&source, &boot, &after_source, &after_boot)?; + let after = native::identity(&mut runner).await?; + check_identity(&source, &boot, &after.source, &after.boot)?; + if report.findings.first().and_then(|v| v.identity.first()) != after.hardware.as_ref() { + return Err("Hardware evidence changed during collection; output not written".into()); + } Ok(report) } diff --git a/crates/omloom-observe/src/native.rs b/crates/omloom-observe/src/native.rs index 5edbe92..8f8d142 100644 --- a/crates/omloom-observe/src/native.rs +++ b/crates/omloom-observe/src/native.rs @@ -1,6 +1,8 @@ use super::command::Runner; use super::{MAX_FACTS, Result, add, api, coverage, finding, local, wire}; use serde_json::Value; +#[cfg(test)] +mod tests; pub(super) fn json(input: &str) -> Result { // Preflight limits both structural fan-out and stack use before serde builds @@ -113,9 +115,46 @@ async fn ps(runner: &mut Runner, script: &str) -> Result { .await .map(|v| v.text) } -pub(super) async fn identity( - runner: &mut Runner, -) -> Result<(&'static str, String, String, String)> { +pub(super) struct Identity { + pub platform: &'static str, + pub source: String, + pub boot: String, + pub name: String, + pub hardware: Option, + pub installation: api::HostIdentityClaim, +} + +fn identity_claim( + kind: api::HostIdentityKind, + namespace: &str, + raw: &str, +) -> Result { + let raw = raw.trim(); + if raw.is_empty() || raw.len() > 128 || raw.chars().any(char::is_control) { + return Err("Invalid native identity claim".into()); + } + Ok(api::HostIdentityClaim { + kind: kind as i32, + namespace: namespace.into(), + digest: super::digest(&raw.to_ascii_lowercase()), + quality: api::HostIdentityQuality::Reported as i32, + }) +} + +fn hardware_claim(namespace: &str, raw: &str) -> Option { + let uuid = uuid::Uuid::parse_str(raw.trim()).ok()?; + if uuid.is_nil() || uuid == uuid::Uuid::max() { + return None; + } + identity_claim( + api::HostIdentityKind::HardwareUuid, + namespace, + &uuid.to_string(), + ) + .ok() +} + +pub(super) async fn identity(runner: &mut Runner) -> Result { match std::env::consts::OS { "macos" => { let device = runner @@ -141,12 +180,18 @@ pub(super) async fn identity( .run("/usr/sbin/sysctl", &["-n", "hw.model"]) .await? .text; - Ok(( - "macos", - format!("{hardware}/{installation}"), - boot.trim().into(), - model.trim().into(), - )) + Ok(Identity { + platform: "macos", + source: format!("{hardware}/{installation}"), + boot: boot.trim().into(), + name: model.trim().into(), + hardware: hardware_claim("apple/platform-uuid", hardware), + installation: identity_claim( + api::HostIdentityKind::InstallationId, + "macos/apfs-volume", + installation, + )?, + }) } "linux" => { let source = runner.run("cat", &["/etc/machine-id"]).await?.text; @@ -155,22 +200,43 @@ pub(super) async fn identity( .await? .text; let name = runner.run("uname", &["-n"]).await?.text; - Ok(( - "linux", - source.trim().into(), - boot.trim().into(), - name.trim().into(), - )) + // DMI may be unreadable without root. Missing hardware evidence + // does not prevent collecting the installation's current facts. + let hardware = match runner.run("cat", &["/sys/class/dmi/id/product_uuid"]).await { + Ok(value) => hardware_claim("smbios/system-uuid", &value.text), + Err(_) => None, + }; + Ok(Identity { + platform: "linux", + source: source.trim().into(), + boot: boot.trim().into(), + name: name.trim().into(), + hardware, + installation: identity_claim( + api::HostIdentityKind::InstallationId, + "linux/machine-id", + &source, + )?, + }) } "windows" => { - let result = ps(runner, "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $os=Get-CimInstance Win32_OperatingSystem; $cs=Get-CimInstance Win32_ComputerSystemProduct; @{source=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid; boot=$os.LastBootUpTime.ToUniversalTime().ToString('o'); name=$cs.Name} | ConvertTo-Json -Compress").await?; + let result = ps(runner, "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $os=Get-CimInstance Win32_OperatingSystem; $cs=Get-CimInstance Win32_ComputerSystemProduct; @{source=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid; boot=$os.LastBootUpTime.ToUniversalTime().ToString('o'); name=$cs.Name; hardware=$cs.UUID} | ConvertTo-Json -Compress").await?; let value = json(&result)?; - Ok(( - "windows", - string(&value, "source")?.into(), - string(&value, "boot")?.into(), - string(&value, "name")?.into(), - )) + Ok(Identity { + platform: "windows", + source: string(&value, "source")?.into(), + boot: string(&value, "boot")?.into(), + name: string(&value, "name")?.into(), + hardware: value + .get("hardware") + .and_then(Value::as_str) + .and_then(|v| hardware_claim("smbios/system-uuid", v)), + installation: identity_claim( + api::HostIdentityKind::InstallationId, + "windows/machine-guid", + string(&value, "source")?, + )?, + }) } _ => Err("Native platform is not supported".into()), } diff --git a/crates/omloom-observe/src/native/tests.rs b/crates/omloom-observe/src/native/tests.rs new file mode 100644 index 0000000..39107bb --- /dev/null +++ b/crates/omloom-observe/src/native/tests.rs @@ -0,0 +1,81 @@ +use super::*; + +// Exercise the native OS source on each qualified host, without assuming that +// an unreadable or unset hardware UUID exists or inventing one from an address. +#[tokio::test] +async fn native_identity_observation_keeps_layers_separate() -> Result<()> { + let value = identity(&mut Runner::new()).await?; + let namespace = match std::env::consts::OS { + "macos" => "macos/apfs-volume", + "linux" => "linux/machine-id", + "windows" => "windows/machine-guid", + _ => return Err("Unsupported native identity platform".into()), + }; + assert!(!value.source.is_empty()); + assert!(!value.boot.is_empty()); + assert_eq!(value.installation.namespace, namespace); + assert_eq!( + value.installation.kind, + api::HostIdentityKind::InstallationId as i32 + ); + assert_eq!(value.installation.digest.len(), 64); + if let Some(hardware) = value.hardware { + assert_eq!(hardware.kind, api::HostIdentityKind::HardwareUuid as i32); + assert_eq!(hardware.quality, api::HostIdentityQuality::Reported as i32); + assert_eq!(hardware.digest.len(), 64); + assert_ne!(hardware.namespace, value.installation.namespace); + } + Ok(()) +} + +// Normalized claims compare across boots, while distinct hardware UUIDs stay distinct. +// A MAC, IP or hostname is never substituted for missing hardware evidence. +#[test] +fn hardware_claims_are_scoped_normalized_and_explicitly_untrusted() { + let first = hardware_claim("smbios/system-uuid", "8D60C19A-489A-40C9-B381-D97E5D568DF8") + .expect("source UUID"); + let second = hardware_claim("smbios/system-uuid", "8d60c19a-489a-40c9-b381-d97e5d568df8") + .expect("source UUID"); + assert_eq!(first, second); + let other = hardware_claim("smbios/system-uuid", "11111111-2222-4333-8444-555555555555") + .expect("distinct source UUID"); + assert_ne!(first.digest, other.digest); + assert_eq!(first.quality, api::HostIdentityQuality::Reported as i32); + assert_eq!(first.digest.len(), 64); + assert!(!first.digest.contains("8d60c19a")); + assert_ne!(first.namespace, "apple/platform-uuid"); + for unknown in [ + "", + "192.0.2.1", + "To be filled by OEM", + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + ] { + assert!(hardware_claim("smbios/system-uuid", unknown).is_none()); + } +} + +// An installation ID remains a distinct namespace and layer even when a source +// happens to report the same bytes as its hardware UUID. +#[test] +fn installation_claims_do_not_collapse_into_hardware_identity() { + let raw = "8d60c19a-489a-40c9-b381-d97e5d568df8"; + let hardware = hardware_claim("smbios/system-uuid", raw).expect("UUID"); + let installation = identity_claim( + api::HostIdentityKind::InstallationId, + "windows/machine-guid", + raw, + ) + .expect("installation"); + assert_eq!(hardware.digest, installation.digest); + assert_ne!(hardware.namespace, installation.namespace); + assert_ne!(hardware.kind, installation.kind); + assert!( + identity_claim( + api::HostIdentityKind::InstallationId, + "linux/machine-id", + "\n" + ) + .is_err() + ); +} diff --git a/crates/omloom-store/Cargo.toml b/crates/omloom-store/Cargo.toml index 6e42720..b6afce2 100644 --- a/crates/omloom-store/Cargo.toml +++ b/crates/omloom-store/Cargo.toml @@ -9,7 +9,7 @@ repository.workspace = true [features] default = ["std"] -std = ["dep:coordinode-embed", "dep:coordinode-core", "dep:omloom-contracts", "dep:sha2", "dep:prost", "dep:csv", "dep:ipnet"] +std = ["dep:coordinode-embed", "dep:coordinode-core", "dep:omloom-contracts", "dep:sha2", "dep:prost", "dep:csv", "dep:ipnet", "dep:serde", "dep:serde_json"] diagnostics = ["std"] [dependencies] @@ -20,6 +20,8 @@ sha2 = { version = "0.10", optional = true } prost = { workspace = true, optional = true } csv = { version = "1.4", optional = true } ipnet = { version = "2.12", optional = true } +serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1", optional = true } [dev-dependencies] coordinode-storage = { git = "https://github.com/structured-world/coordinode", rev = "1862962a3da20e5e57d7ab5126e69bd5817d7c61" } diff --git a/crates/omloom-store/src/disk.rs b/crates/omloom-store/src/disk.rs index 79dcb99..4f4ac99 100644 --- a/crates/omloom-store/src/disk.rs +++ b/crates/omloom-store/src/disk.rs @@ -22,8 +22,9 @@ use std::{ type Properties = BTreeMap; type Result = std::result::Result; const ROOT: &str = "installation"; -const SCHEMA_VERSION: u64 = 10; +const SCHEMA_VERSION: u64 = 12; const MAX_CHILDREN: usize = 2048; +mod deltas; mod inventory; mod lifecycle; mod operations; @@ -65,7 +66,8 @@ impl From for Error { match e { DatabaseError::RevisionMismatch { .. } | DatabaseError::UniqueViolation { .. } - | DatabaseError::TransactionConflict { .. } => Self::Conflict, + | DatabaseError::TransactionConflict { .. } + | DatabaseError::InvariantRefused { .. } => Self::Conflict, DatabaseError::Budget(coordinode_core::budget::BudgetStop::Memory { .. }) => { Self::Limit } @@ -662,6 +664,7 @@ impl Store { ("omloom_group", "group"), ("omloom_party_lookup", "party_lookup"), ("omloom_request_account", "request_account"), + ("omloom_claim", "claim"), ] { match db.execute_cypher(&format!("CREATE INDEX {name} ON :OmRow({property})")) { Ok(_) => {} @@ -718,6 +721,7 @@ impl Store { workspace::upgrade_asset_names(&store)?; } if version < SCHEMA_VERSION { + workspace::host_identity::upgrade(&store)?; // Publish the format only after all resumable projections are ready. let mut tx = store.begin()?; if number(&tx.root, "schema")? < SCHEMA_VERSION { @@ -741,6 +745,7 @@ impl Store { "MATCH (n:OmRow {group:'probe'}) RETURN n.key AS key LIMIT 1", "MATCH (n:OmRow {party_lookup:'probe'}) RETURN n.key AS key LIMIT 1", "MATCH (n:OmRow {request_account:'probe'}) RETURN n.key AS key LIMIT 1", + "MATCH (n:OmRow {claim:'probe'}) RETURN n.key AS key LIMIT 1", ] { store .db @@ -823,6 +828,7 @@ impl Store { id: Some(tx), root, changed: false, + inverse: None, }) })(); if result.is_err() { @@ -874,6 +880,7 @@ struct Tx<'a> { id: Option, root: Properties, changed: bool, + inverse: Option, } impl Tx<'_> { fn query(&self, q: &str, p: Option>) -> Result> { @@ -1015,6 +1022,9 @@ impl Tx<'_> { if old == Some(&data) { return Ok(false); } + if let Some(capture) = &mut self.inverse { + capture.record(key, old, class)?; + } self.counters(old, Some(&data))?; let query = if old.is_some() { "MATCH (n:OmRow {key:$key}) SET n = $p" @@ -1037,6 +1047,9 @@ impl Tx<'_> { } fn delete_projection(&mut self, key: &str) -> Result<()> { if let Some(old) = self.get(key)? { + if let Some(capture) = &mut self.inverse { + capture.record(key, Some(&old), Class::Inventory)?; + } self.counters(Some(&old), None)?; self.store.db.execute_in_transaction( self.id.ok_or(Error::Corrupt)?, diff --git a/crates/omloom-store/src/disk/deltas.rs b/crates/omloom-store/src/disk/deltas.rs new file mode 100644 index 0000000..2aa0664 --- /dev/null +++ b/crates/omloom-store/src/disk/deltas.rs @@ -0,0 +1,68 @@ +//! Bounded inverse custody for an explicit inventory correction. Only changed +//! rows are captured, never a subtree, grants or external operation state. +use super::*; +use serde::{Deserialize, Serialize}; + +pub(super) const ROWS: usize = 128; +pub(super) const BYTES: usize = 256 * 1024; + +#[derive(Default, Serialize, Deserialize)] +pub(super) struct Capture { + pub rows: BTreeMap>, + #[serde(skip)] + bytes: usize, +} +impl Capture { + pub fn record(&mut self, key: &str, old: Option<&Properties>, class: Class) -> Result<()> { + let class = old + .map(|p| number(p, "class")) + .transpose()? + .unwrap_or(class as u64); + if ![Class::Inventory as u64, Class::Checkpoint as u64].contains(&class) + || self.rows.contains_key(key) + { + return Ok(()); + } + let size = old.map(|p| number(p, "size")).transpose()?.unwrap_or(0); + let charge = usize::try_from(size) + .map_err(|_| Error::Limit)? + .checked_mul(6) + .and_then(|v| v.checked_add(key.len() + 256)) + .ok_or(Error::Limit)?; + let next = self.bytes.checked_add(charge).ok_or(Error::Limit)?; + if self.rows.len() == ROWS || next > BYTES { + return Err(Error::Limit); + } + self.bytes = next; + self.rows.insert(key.into(), old.cloned()); + Ok(()) + } + pub fn encode(&self) -> Result> { + struct Output(Vec); + impl std::io::Write for Output { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > BYTES - self.0.len() { + return Err(std::io::ErrorKind::OutOfMemory.into()); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut output = Output(Vec::with_capacity(BYTES)); + serde_json::to_writer(&mut output, self).map_err(|_| Error::Limit)?; + Ok(output.0) + } + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > BYTES { + return Err(Error::Corrupt); + } + let value: Self = serde_json::from_slice(bytes).map_err(|_| Error::Corrupt)?; + if value.rows.len() > ROWS || value.bytes > BYTES { + return Err(Error::Corrupt); + } + Ok(value) + } +} diff --git a/crates/omloom-store/src/disk/inventory.rs b/crates/omloom-store/src/disk/inventory.rs index 6ef8de1..d60c14d 100644 --- a/crates/omloom-store/src/disk/inventory.rs +++ b/crates/omloom-store/src/disk/inventory.rs @@ -629,6 +629,25 @@ impl Tx<'_> { expected: Option, actor: &str, now: u64, + ) -> Result { + self.save_relation_inner(relation, expected, actor, now, false) + } + pub(super) fn save_rebound_relation( + &mut self, + relation: &wire::Relation, + expected: u64, + actor: &str, + now: u64, + ) -> Result { + self.save_relation_inner(relation, Some(expected), actor, now, true) + } + fn save_relation_inner( + &mut self, + relation: &wire::Relation, + expected: Option, + actor: &str, + now: u64, + detached: bool, ) -> Result { let from = AssetRef::try_from(relation.from.as_ref().ok_or(Error::Invalid)?) .map_err(|_| Error::Invalid)?; @@ -664,7 +683,7 @@ impl Tx<'_> { return Err(Error::Conflict); } if let Some(old) = &old { - self.check_software_relation_replacement(old, from, from_kind, to, kind)?; + self.check_software_relation_replacement(old, from, from_kind, to, kind, detached)?; } let placement = match kind { wire::RelationKind::InstalledIn => Some((from, to)), @@ -674,7 +693,7 @@ impl Tx<'_> { if let Some((child, parent)) = placement { self.check_physical_parent(child, parent, &key)?; } else if kind == wire::RelationKind::RunsOn { - self.check_placement_cycle(from, to, &key)?; + self.check_placement_cycle(from, to, Some(&key))?; } if kind == wire::RelationKind::Contains && from_kind == wire::AssetKind::Interface @@ -690,24 +709,31 @@ impl Tx<'_> { ("kind", Value::Int(relation.kind.into())), ("authority", text(&relation.authority_id)), ])); - if old - .as_ref() - .is_some_and(|prior| data.iter().all(|(k, v)| prior.get(k) == Some(v))) + if !detached + && old + .as_ref() + .is_some_and(|prior| data.iter().all(|(k, v)| prior.get(k) == Some(v))) { return expected.ok_or(Error::Corrupt); } let revision = expected.unwrap_or(0).checked_add(1).ok_or(Error::Limit)?; + if kind == wire::RelationKind::RunsOn && from_kind == wire::AssetKind::VirtualMachine { + self.require_no_pending_operations(from)?; + } if kind == wire::RelationKind::RunsOn && from_kind == wire::AssetKind::Container { self.bind_container_runtime(from, Some(to))?; } data.insert("revision".into(), int(revision)?); - let source_changed = old.as_ref().is_none_or(|p| { - p.get("asset") != data.get("asset") || p.get("incarnation") != data.get("incarnation") - }); - let target_changed = old.as_ref().is_none_or(|p| { - p.get("to_asset") != data.get("to_asset") - || p.get("to_incarnation") != data.get("to_incarnation") - }); + let source_changed = detached + || old.as_ref().is_none_or(|p| { + p.get("asset") != data.get("asset") + || p.get("incarnation") != data.get("incarnation") + }); + let target_changed = detached + || old.as_ref().is_none_or(|p| { + p.get("to_asset") != data.get("to_asset") + || p.get("to_incarnation") != data.get("to_incarnation") + }); self.put_against(&key, "relations", Class::Inventory, data, old.as_ref())?; if source_changed { if old.is_some() { @@ -736,16 +762,16 @@ impl Tx<'_> { self.audit(&key, actor, "relation.accepted", revision, now)?; Ok(revision) } - fn physical_parent(&self, child: AssetRef, skip: &str) -> Result> { + fn physical_parent(&self, child: AssetRef, skip: Option<&str>) -> Result> { let mut parents = Vec::with_capacity(2); for (query, kinds, incoming) in [ ( - "MATCH (a:OmRow {key:$asset})-[:OmRelationFrom]->(r:OmRow) WHERE r.kind IN $kinds AND r.key<>$skip RETURN properties(r) AS p LIMIT 2", + "MATCH (a:OmRow {key:$asset})-[:OmRelationFrom]->(r:OmRow) WHERE r.kind IN $kinds AND ($skip IS NULL OR r.key<>$skip) RETURN properties(r) AS p LIMIT 2", vec![wire::RelationKind::InstalledIn], false, ), ( - "MATCH (a:OmRow {key:$asset})<-[:OmRelationTo]-(r:OmRow) WHERE r.kind IN $kinds AND r.key<>$skip RETURN properties(r) AS p LIMIT 2", + "MATCH (a:OmRow {key:$asset})<-[:OmRelationTo]-(r:OmRow) WHERE r.kind IN $kinds AND ($skip IS NULL OR r.key<>$skip) RETURN properties(r) AS p LIMIT 2", vec![wire::RelationKind::Contains, wire::RelationKind::HasPort], true, ), @@ -754,7 +780,7 @@ impl Tx<'_> { query, params([ ("asset", text(asset_key(child))), - ("skip", text(skip)), + ("skip", skip.map_or(Value::Null, text)), ( "kinds", Value::Array( @@ -788,14 +814,19 @@ impl Tx<'_> { Ok(parents.pop()) } fn check_physical_parent(&self, child: AssetRef, parent: AssetRef, skip: &str) -> Result<()> { - if self.physical_parent(child, skip)?.is_some() { + if self.physical_parent(child, Some(skip))?.is_some() { return Err(Error::Conflict); } - self.check_placement_cycle(child, parent, skip) + self.check_placement_cycle(child, parent, Some(skip)) } - fn check_placement_cycle(&self, child: AssetRef, parent: AssetRef, skip: &str) -> Result<()> { + pub(super) fn check_placement_cycle( + &self, + child: AssetRef, + parent: AssetRef, + skip: Option<&str>, + ) -> Result<()> { // Execution placement and containment have opposite stored arrows but share ancestry. - // Multiple execution parents are traversed without imposing physical-parent uniqueness. + // Legacy multiple hosts are still traversed; None excludes no existing relation. let mut pending = vec![parent]; let mut seen = std::collections::BTreeSet::from([parent]); while let Some(node) = pending.pop() { @@ -804,8 +835,8 @@ impl Tx<'_> { } let physical = self.physical_parent(node, skip)?; let hosts = self.query( - "MATCH (a:OmRow {key:$asset})-[:OmRelationFrom]->(r:OmRow)-[:OmRelationTo]->(p:OmRow) WHERE r.kind=$kind AND r.key<>$skip RETURN properties(p) AS p LIMIT 129", - params([("asset", text(asset_key(node))), ("skip", text(skip)), ("kind", Value::Int(wire::RelationKind::RunsOn as i32 as i64))]), + "MATCH (a:OmRow {key:$asset})-[:OmRelationFrom]->(r:OmRow)-[:OmRelationTo]->(p:OmRow) WHERE r.kind=$kind AND ($skip IS NULL OR r.key<>$skip) RETURN properties(p) AS p LIMIT 129", + params([("asset", text(asset_key(node))), ("skip", skip.map_or(Value::Null, text)), ("kind", Value::Int(wire::RelationKind::RunsOn as i32 as i64))]), )?; if hosts.len() > 128 { return Err(Error::Limit); diff --git a/crates/omloom-store/src/disk/operations.rs b/crates/omloom-store/src/disk/operations.rs index 163bb5a..8158756 100644 --- a/crates/omloom-store/src/disk/operations.rs +++ b/crates/omloom-store/src/disk/operations.rs @@ -1,5 +1,15 @@ use super::*; +impl Tx<'_> { + pub(super) fn require_no_pending_operations(&self, target: AssetRef) -> Result<()> { + let pending=self.query("MATCH (n:OmRow) WHERE n.parent='operations' AND n.class=$class AND n.asset=$asset AND n.incarnation=$incarnation RETURN properties(n) AS p LIMIT 1",params([("class",int(Class::Pending as u64)?),("asset",text(target.asset.to_string())),("incarnation",text(target.incarnation.to_string()))]))?; + if !pending.is_empty() { + return Err(Error::UnsafeTransition); + } + Ok(()) + } +} + enum Change<'a> { Dispatch, Evidence { diff --git a/crates/omloom-store/src/disk/workspace.rs b/crates/omloom-store/src/disk/workspace.rs index e705510..df31291 100644 --- a/crates/omloom-store/src/disk/workspace.rs +++ b/crates/omloom-store/src/disk/workspace.rs @@ -8,9 +8,11 @@ use std::collections::BTreeSet; mod accounts; mod addresses; mod assets; +mod correction; mod current_facts; mod definitions; mod exchange; +pub(super) mod host_identity; mod host_import; mod interface_facts; mod network; @@ -18,7 +20,9 @@ pub(super) mod network_facts; mod projection; mod responsibility; mod software; +mod source_layout; mod views; +mod vm_placement; mod working; pub const MAX_PAGE: usize = 100; @@ -306,6 +310,32 @@ impl Store { working.as_ref().ok_or(Error::Invalid)?, now, ), + C::CompareHostIdentity(value) => self.workspace_compare_host_identity( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + ), + C::CorrectIdentity(value) => self.workspace_correct_identity( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + now, + ), + C::FindIdentityCandidates(value) => self.workspace_identity_candidates( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + ), + C::ResolveIdentity(value) => self.workspace_resolve_identity( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + ), + C::PrepareVmPlacement(value) => self.workspace_prepare_vm_placement( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + ), C::Export(export) => self.workspace_export(context, export), C::Network(network) => self.workspace_network( context, diff --git a/crates/omloom-store/src/disk/workspace/assets.rs b/crates/omloom-store/src/disk/workspace/assets.rs index 92e6e1c..b9b4704 100644 --- a/crates/omloom-store/src/disk/workspace/assets.rs +++ b/crates/omloom-store/src/disk/workspace/assets.rs @@ -613,8 +613,14 @@ impl Store { " OPTIONAL MATCH {path} WHERE {owner} WITH {}, p IS NOT NULL AS {flag}", bindings.join(", ") )); - bindings.push(flag.into()); - clauses.push(format!("NOT {flag}")); + if reverse { + bindings.push(flag.into()); + clauses.push(format!("NOT {flag}")); + } else { + // Owned components cannot become roots in the reverse + // branch. Reject them before performing another probe. + predicate_joins.push(format!(" WHERE NOT {flag}")); + } } } else { let owner = browse_clause(context, &mut values, None, &child_kinds); @@ -1286,6 +1292,11 @@ impl Store { current_addresses, relations_next, network_next: None, + identity: if read.relations_cursor.is_none() { + tx.host_identity(context, target, asset.kind)? + } else { + Vec::new() + }, }; if read.relations_cursor.is_none() { tx.read_network_page(context, target, asset.kind, working, None, &mut detail)?; @@ -1536,6 +1547,13 @@ impl Store { } tx.check_relationship(context, &selection, &existing)?; if request.remove { + if existing.kind == wire::RelationKind::RunsOn as i32 { + let guest = parse_target(existing.from.as_ref())?; + if number(&tx.asset(guest)?, "kind")? == wire::AssetKind::VirtualMachine as u64 + { + tx.require_no_pending_operations(guest)?; + } + } if existing.kind == wire::RelationKind::ReleaseOf as i32 && tx.release_is_installed(parse_target(existing.from.as_ref())?)? { @@ -1775,7 +1793,7 @@ impl Tx<'_> { } } } - fn workspace_remove( + pub(super) fn workspace_remove( &mut self, context: &Context, target: AssetRef, @@ -1788,10 +1806,7 @@ impl Tx<'_> { let key = asset_key(target); // Existing operation/retirement code owns uncertain effects and identity fences. self.require_no_account_references(target)?; - let pending = self.query("MATCH (n:OmRow) WHERE n.parent='operations' AND n.class=$class AND n.asset=$asset AND n.incarnation=$incarnation RETURN properties(n) AS p LIMIT 1",params([("class", int(Class::Pending as u64)?), ("asset", text(target.asset.to_string())), ("incarnation", text(target.incarnation.to_string()))]))?; - if !pending.is_empty() { - return Err(Error::UnsafeTransition); - } + self.require_no_pending_operations(target)?; let linked = self.query("MATCH (n:OmRow) WHERE n.parent='relations' AND (n.asset=$asset OR n.to_asset=$asset) RETURN properties(n) AS p LIMIT 1",params([("asset", text(target.asset.to_string()))]))?; if !linked.is_empty() { return Err(Error::Conflict); @@ -1829,7 +1844,7 @@ impl Tx<'_> { self.delete(&group_key)?; } self.retire_current_facts(target)?; - let mappings = self.query("MATCH (n:OmRow {asset:$asset}) WHERE n.parent='csv-mappings' AND n.incarnation=$incarnation RETURN properties(n) AS p LIMIT 2049", params([("asset", text(target.asset.to_string())), ("incarnation", text(target.incarnation.to_string()))]))?; + let mappings = self.query("MATCH (n:OmRow {asset:$asset}) WHERE n.parent='csv-mappings' AND n.incarnation=$incarnation AND n.class=$class RETURN properties(n) AS p LIMIT 2049", params([("asset", text(target.asset.to_string())), ("incarnation", text(target.incarnation.to_string())), ("class", Value::Int(Class::Inventory as i64))]))?; if mappings.len() > MAX_CHILDREN { return Err(Error::Limit); } diff --git a/crates/omloom-store/src/disk/workspace/correction.rs b/crates/omloom-store/src/disk/workspace/correction.rs new file mode 100644 index 0000000..388be4d --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/correction.rs @@ -0,0 +1,543 @@ +//! Reviewed corrections reuse inventory validation, transaction CAS, audit and +//! finite review/detail custody. No operation or account authority is retargeted. +use super::*; +mod sources; +mod undo; + +fn pair(value: &api::IdentityMapping) -> Result<(AssetRef, AssetRef)> { + Ok(( + parse_target(value.duplicate.as_ref())?, + parse_target(value.retained.as_ref())?, + )) +} +fn strip(mut value: Properties) -> Properties { + operations::strip_envelope(&mut value); + value +} +impl Tx<'_> { + fn correction_guard( + &self, + context: &Context, + target: AssetRef, + working: &api::WorkingContext, + ) -> Result { + let row = self.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + if !context.field(target, kind, "relations", false) { + return Err(Error::Denied); + } + self.selection(context, working, false)? + .contains(target, self)?; + for field in ["identity_correction", "relations", "remove"] { + if !context.field(target, kind, field, true) { + return Err(Error::Denied); + } + } + self.require_no_account_references(target)?; + self.require_no_ipam_state(target)?; + self.require_no_pending_operations(target)?; + Ok(row) + } + fn correction_fields( + &mut self, + context: &Context, + mapping: &api::IdentityMapping, + kind: i32, + working: &api::WorkingContext, + now: u64, + ) -> Result<()> { + let (from, to) = pair(mapping)?; + let rows = self.fact_rows(&asset_key(from), MAX_FIELDS + 5)?; + let choices: BTreeMap<_, _> = mapping + .fields + .iter() + .map(|v| (v.name.as_str(), v.use_duplicate)) + .collect(); + if choices.len() != mapping.fields.len() || choices.len() > MAX_FIELDS { + return Err(Error::Invalid); + } + let mut used = BTreeSet::new(); + let mut changes = Vec::new(); + for row in rows { + if row.get("layer") == Some(&text("intent")) { + let name = string(&row, "field")?; + let take = choices.get(name.as_str()).ok_or(Error::Conflict)?; + for target in [from, to] { + if !context.field(target, kind, &name, false) + || ((target == from || *take) && !context.field(target, kind, &name, true)) + { + return Err(Error::Denied); + } + } + used.insert(name); + if *take { + changes.push(inventory::read_field(&row)?); + } + } else if row.contains_key("facet") { + // Declarations are not transferred. An inherited binding is + // recomputed through its changed relation; explicit conflicts + // must first be resolved in the responsibility editor. + if number(&row, "facet")? != api::ResponsibilityFacet::Creator as u64 + && number(&row, "mode")? != api::AssignmentMode::Inherit as u64 + { + return Err(Error::Conflict); + } + } else { + return Err(Error::Conflict); + } + } + if used.len() != choices.len() { + return Err(Error::Invalid); + } + if !changes.is_empty() { + self.context_write( + context, + api::Write { + target: Some(target_wire(to)), + expected: Some(number(&self.asset(to)?, "revision")?), + kind, + changes, + ..Default::default() + }, + working, + now, + )?; + } + Ok(()) + } + fn apply_correction( + &mut self, + context: &Context, + request: &api::IdentityCorrection, + working: &api::WorkingContext, + now: u64, + ) -> Result> { + use api::IdentityDecision as D; + let decision = D::try_from(request.decision).map_err(|_| Error::Invalid)?; + if request.mappings.is_empty() + || request.mappings.len() > 8 + || !matches!( + decision, + D::DistinctInstallations | D::DuplicateInstallation + ) + { + return Err(Error::Invalid); + } + let mut mappings = BTreeMap::new(); + let mut kinds = BTreeMap::new(); + let mut relations = BTreeMap::new(); + for mapping in &request.mappings { + let (from, to) = pair(mapping)?; + if from == to || mappings.insert(from, to).is_some() { + return Err(Error::Invalid); + } + let old = self.correction_guard(context, from, working)?; + let retained = self.correction_guard(context, to, working)?; + let kind = number(&old, "kind")? as i32; + if kind != number(&retained, "kind")? as i32 + || !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device + | wire::AssetKind::VirtualMachine + | wire::AssetKind::SoftwareInstallation + | wire::AssetKind::Interface) + ) + { + return Err(Error::Invalid); + } + if decision == D::DistinctInstallations + && !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device | wire::AssetKind::VirtualMachine) + ) + { + return Err(Error::Invalid); + } + kinds.insert(from, kind); + for relation in self.working_relations(from)? { + if relations.len() == 128 { + return Err(Error::Limit); + } + relations.insert(relation.relation_id.clone(), relation); + } + } + if mappings.values().any(|v| mappings.contains_key(v)) { + return Err(Error::Invalid); + } + let mut attachments = BTreeSet::new(); + for mapping in &request.mappings { + if mapping.attachments.len() > 128 { + return Err(Error::Limit); + } + for id in &mapping.attachments { + if !relations.contains_key(id) || !attachments.insert(id.as_str()) { + return Err(Error::Invalid); + } + } + } + for relation in relations.values() { + if matches!( + wire::RelationKind::try_from(relation.kind), + Ok(wire::RelationKind::HasPort + | wire::RelationKind::Carries + | wire::RelationKind::InstalledIn + | wire::RelationKind::ConnectedTo + | wire::RelationKind::On) + ) && !attachments.contains(relation.relation_id.as_str()) + { + return Err(Error::Conflict); + } + } + let selection = self.selection(context, working, false)?; + let authority = string( + &self.get("workspace/authority")?.ok_or(Error::Missing)?, + "authority", + )?; + let mut impacts = Vec::new(); + // Remove old attachments before validating the final graph, so an + // intermediate exclusive-parent conflict cannot choose the result. + for before in relations.values() { + let (from, to) = self.check_relationship(context, &selection, before)?; + for endpoint in [from, to] { + let kind = number(&self.asset(endpoint)?, "kind")? as i32; + if !context.field(endpoint, kind, "relations", false) { + return Err(Error::Denied); + } + } + if before.authority_id != authority { + return Err(Error::Conflict); + } + } + for mapping in &request.mappings { + let (from, _) = pair(mapping)?; + self.correction_fields(context, mapping, kinds[&from], working, now)?; + } + for before in relations.values() { + self.unlink_correction_relation(&before.relation_id)?; + } + for before in relations.into_values() { + let from = parse_target(before.from.as_ref())?; + let to = parse_target(before.to.as_ref())?; + let mut after = before.clone(); + after.from = Some(target_wire(*mappings.get(&from).unwrap_or(&from))); + after.to = Some(target_wire(*mappings.get(&to).unwrap_or(&to))); + if after.from == after.to { + // Only an internal containment edge can become redundant. + if !matches!( + wire::RelationKind::try_from(after.kind), + Ok(wire::RelationKind::Contains) + ) { + return Err(Error::Conflict); + } + self.delete(&format!("relation/{}", before.relation_id))?; + impacts.push(api::IdentityImpact { + before: Some(before), + after: None, + }); + continue; + } + // Every original endpoint and mapped destination was authorized + // before detachment. Intermediate topology cannot redefine scope. + let redundant = self + .working_relations(parse_target(after.from.as_ref())?)? + .into_iter() + .find(|r| r.from == after.from && r.to == after.to && r.kind == after.kind); + if let Some(retained) = redundant { + self.rebind_correction_responsibility(context, &before, &retained)?; + self.delete(&format!("relation/{}", before.relation_id))?; + impacts.push(api::IdentityImpact { + before: Some(before), + after: Some(retained), + }); + continue; + } + after.revision = before.revision.checked_add(1).ok_or(Error::Limit)?; + self.save_rebound_relation(&after, before.revision, &context.actor, now)?; + self.rebind_correction_responsibility(context, &before, &after)?; + impacts.push(api::IdentityImpact { + before: Some(before), + after: Some(after), + }); + } + for mapping in &request.mappings { + let (from, to) = pair(mapping)?; + self.move_correction_sources(context, mapping, from, to, kinds[&from])?; + // Native and explicitly selected field state have moved. The + // generic delete guard still owns all remaining bindings. + let old = self.asset(from)?; + self.workspace_remove(context, from, &old, now)?; + let key = format!("removed/{}", from.asset); + let mut fence = target_props(from); + fence.insert("to_asset".into(), text(to.asset.to_string())); + fence.insert("to_incarnation".into(), text(to.incarnation.to_string())); + self.put(&key, "removed", Class::Checkpoint, fence)?; + self.workspace_advance(to, &context.actor, "identity.corrected", now)?; + } + Ok(impacts) + } + fn unlink_correction_relation(&mut self, id: &str) -> Result<()> { + let key = format!("relation/{id}"); + for query in [ + "MATCH (r:OmRow {key:$key})<-[e:OmRelationFrom]-() DELETE e", + "MATCH (r:OmRow {key:$key})-[e:OmRelationTo]->() DELETE e", + ] { + self.store.db.execute_in_transaction( + self.id.ok_or(Error::Corrupt)?, + query, + params([("key", text(&key))]), + )?; + } + Ok(()) + } + fn rebind_correction_responsibility( + &mut self, + context: &Context, + before: &wire::Relation, + after: &wire::Relation, + ) -> Result<()> { + for endpoint in [&before.from, &before.to].into_iter().flatten() { + let target = parse_target(Some(endpoint))?; + for mut row in self.fact_rows(&asset_key(target), MAX_FIELDS + 5)? { + if row.get("source_relation") != Some(&text(&before.relation_id)) { + continue; + } + if number(&row, "source_binding_revision")? != before.revision { + continue; + } + let facet = number(&row, "facet")?; + let field = match api::ResponsibilityFacet::try_from(facet as i32) { + Ok(api::ResponsibilityFacet::ClaimedOwner) => "claimed_owner", + Ok(api::ResponsibilityFacet::Operator) => "operator", + _ => return Err(Error::Conflict), + }; + if !context.field( + target, + number(&self.asset(target)?, "kind")? as i32, + field, + true, + ) { + return Err(Error::Denied); + } + let key = string(&row, "key")?; + row = strip(row); + row.insert("source_binding_revision".into(), int(after.revision)?); + row.insert("source_relation".into(), text(&after.relation_id)); + self.put(&key, &asset_key(target), Class::Inventory, row)?; + } + } + Ok(()) + } +} + +impl Store { + pub(super) fn workspace_correct_identity( + &self, + context: &Context, + request: api::IdentityCorrection, + working: &api::WorkingContext, + now: u64, + ) -> Result { + if let Some(id) = &request.undo { + if request.preview.is_some() || request.decision != 0 || !request.mappings.is_empty() { + return Err(Error::Invalid); + } + return self.undo_identity_correction(context, id, working, now); + } + if let Some(id) = &request.preview { + id.parse::().map_err(|_| Error::Invalid)?; + if request.decision != 0 || !request.mappings.is_empty() { + return Err(Error::Invalid); + } + let mut tx = self.begin()?; + let key = format!("preview/{id}"); + let row = match tx.get(&key)? { + Some(row) => row, + None => { + drop(tx); + return self.identity_correction_receipt(context, id, working); + } + }; + if string(&row, "owner")? != context.actor + || number(&row, "source_binding_revision")? != context.authority_revision + { + return Err(Error::Denied); + } + if number(&row, "expires")? <= now { + return Err(Error::Expired); + } + if number(&row, "revision")? != number(&tx.root, "revision")? { + return Err(Error::Conflict); + } + let mut selector = working.clone(); + selector.expected = None; + if row.get("object_kind") != Some(&text("identity-correction")) + || binary(&row, "working")? != selector.encode_to_vec() + { + return Err(Error::Conflict); + } + let input = api::IdentityCorrection::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + tx.inverse = Some(deltas::Capture::default()); + tx.apply_correction(context, &input, working, now)?; + let inverse = tx.inverse.take().ok_or(Error::Corrupt)?; + let until = now + .checked_add(self.policy.detail_age_ms) + .ok_or(Error::Limit)?; + let revision = number(&tx.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?; + tx.put( + &format!("correction/{id}"), + "detail/identity-corrections", + Class::Detail, + props([ + ("owner", text(&context.actor)), + ("at", int(now)?), + ("expires", int(until)?), + ("revision", int(revision)?), + ("source_binding_revision", int(context.authority_revision)?), + ("payload", Value::Binary(inverse.encode()?)), + ("query", Value::Binary(input.encode_to_vec())), + ]), + )?; + tx.delete(&key)?; + let retained = input + .mappings + .iter() + .filter_map(|v| v.retained.clone()) + .collect(); + let revision = tx.commit()?; + return Ok(reply(api::reply::Result::IdentityCorrected( + api::IdentityCorrectionApplied { + revision, + id: id.clone(), + undo_until: until, + retained, + }, + ))); + } + let mut tx = self.begin()?; + if request.decision == api::IdentityDecision::KeepSeparate as i32 { + if !request.mappings.is_empty() { + return Err(Error::Invalid); + } + return Ok(mutation(tx.commit()?, Vec::new())); + } + let base = number(&tx.root, "revision")?; + if working.expected != Some(base) { + return Err(Error::Conflict); + } + drop(tx); + let relations = { + let mut dry = self.begin()?; + dry.inverse = Some(deltas::Capture::default()); + let result = dry.apply_correction(context, &request, working, now)?; + dry.inverse.as_ref().ok_or(Error::Corrupt)?.encode()?; + result + }; + tx = self.begin()?; + if number(&tx.root, "revision")? != base { + return Err(Error::Conflict); + } + let payload = request.encode_to_vec(); + let mut selector = working.clone(); + selector.expected = None; + // One replaceable preview per actor, shared with ordinary import. + let previous=tx.query("MATCH (n:OmRow {parent:'workspace-previews'}) WHERE n.owner=$owner RETURN properties(n) AS p LIMIT 5",params([("owner",text(&context.actor))]))?; + if previous.len() > 4 { + return Err(Error::Limit); + } + for old in previous { + let key = string(&old, "key")?; + if old.get("object_kind") == Some(&text("identity-correction")) + && binary(&old, "payload")? == payload + && number(&old, "revision")? == base + && number(&old, "expires")? > now + && number(&old, "source_binding_revision")? == context.authority_revision + && binary(&old, "working")? == selector.encode_to_vec() + { + return Ok(reply(api::reply::Result::IdentityCorrection( + api::IdentityCorrectionView { + id: key.strip_prefix("preview/").ok_or(Error::Corrupt)?.into(), + revision: base, + mappings: request.mappings, + relations, + expires_at: number(&old, "expires")?, + }, + ))); + } + for child in tx.children(&key)? { + tx.delete(&string(&child, "key")?)?; + } + tx.delete(&key)?; + } + let id = AuthorityId::generate().to_string(); + let revision = base.checked_add(1).ok_or(Error::Limit)?; + let expires_at = now + .checked_add(self.policy.review_age_ms) + .ok_or(Error::Limit)?; + tx.put( + &format!("preview/{id}"), + "workspace-previews", + Class::Review, + props([ + ("owner", text(&context.actor)), + ("revision", int(revision)?), + ("expires", int(expires_at)?), + ("source_binding_revision", int(context.authority_revision)?), + ("object_kind", text("identity-correction")), + ("working", Value::Binary(selector.encode_to_vec())), + ("payload", Value::Binary(payload)), + ]), + )?; + tx.commit()?; + Ok(reply(api::reply::Result::IdentityCorrection( + api::IdentityCorrectionView { + id, + revision, + mappings: request.mappings, + relations, + expires_at, + }, + ))) + } + fn identity_correction_receipt( + &self, + context: &Context, + id: &str, + working: &api::WorkingContext, + ) -> Result { + let tx = self.begin()?; + let row = tx.get(&format!("correction/{id}"))?.ok_or(Error::Expired)?; + if string(&row, "owner")? != context.actor + || number(&row, "source_binding_revision")? != context.authority_revision + { + return Err(Error::Denied); + } + let receipt = if row.get("retired") == Some(&Value::Bool(true)) { + api::IdentityCorrectionApplied::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)? + } else { + let input = api::IdentityCorrection::decode(binary(&row, "query")?) + .map_err(|_| Error::Corrupt)?; + api::IdentityCorrectionApplied { + revision: number(&row, "revision")?, + id: id.into(), + undo_until: number(&row, "expires")?, + retained: input + .mappings + .into_iter() + .filter_map(|v| v.retained) + .collect(), + } + }; + let selection = tx.selection(context, working, false)?; + for reference in &receipt.retained { + let target = parse_target(Some(reference))?; + tx.visible_asset(context, target)?; + selection.contains(target, &tx)?; + } + tx.commit()?; + Ok(reply(api::reply::Result::IdentityCorrected(receipt))) + } +} diff --git a/crates/omloom-store/src/disk/workspace/correction/sources.rs b/crates/omloom-store/src/disk/workspace/correction/sources.rs new file mode 100644 index 0000000..02d99b3 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/correction/sources.rs @@ -0,0 +1,151 @@ +use super::super::source_layout::{Family, Relocation}; +use super::*; + +fn renamed(mapping: &api::IdentityMapping, name: &str) -> String { + for choice in &mapping.sources { + if name == choice.before { + return choice.after.clone(); + } + if let Some(part) = name.strip_prefix(&format!("{} / ", choice.before)) { + return format!("{} / {part}", choice.after); + } + } + name.into() +} +fn require(context: &Context, from: AssetRef, to: AssetRef, kind: i32, field: &str) -> Result<()> { + for target in [from, to] { + if !context.field(target, kind, field, false) || !context.field(target, kind, field, true) { + return Err(Error::Denied); + } + } + Ok(()) +} +impl Tx<'_> { + pub(super) fn move_correction_sources( + &mut self, + context: &Context, + mapping: &api::IdentityMapping, + from: AssetRef, + to: AssetRef, + kind: i32, + ) -> Result<()> { + if mapping.sources.len() > 8 { + return Err(Error::Limit); + } + let mut choices = BTreeSet::new(); + for choice in &mapping.sources { + if choice.before.is_empty() + || choice.after.is_empty() + || choice.before.len() > 100 + || choice.after.len() > 80 + || choice + .before + .chars() + .chain(choice.after.chars()) + .any(char::is_control) + || !choices.insert(choice.before.as_str()) + { + return Err(Error::Invalid); + } + } + let identities = self.fact_rows(&host_identity::parent(from), 32)?; + if identities.len() + self.fact_rows(&host_identity::parent(to), 32)?.len() > 32 { + return Err(Error::Limit); + } + for row in identities { + require(context, from, to, kind, "identity_evidence")?; + let key = string(&row, "key")?; + let mut data = strip(row); + data.extend(target_props(to)); + data.insert( + "source".into(), + text(renamed(mapping, &string(&data, "source")?)), + ); + self.put(&key, &host_identity::parent(to), Class::Inventory, data)?; + } + let host_parent = format!("{}/host-mappings", asset_key(to)); + let mappings = + self.fact_rows(&format!("{}/host-mappings", asset_key(from)), MAX_CHILDREN)?; + if mappings.len() + self.fact_rows(&host_parent, MAX_CHILDREN)?.len() > MAX_CHILDREN { + return Err(Error::Limit); + } + for row in mappings { + require(context, from, to, kind, "identity_evidence")?; + let key = string(&row, "key")?; + let source = row + .get("namespace") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or(string(&row, "source")?); + let mut data = strip(row); + data.extend(target_props(to)); + let label = renamed(mapping, &source); + if label != string(&data, "source")? { + data.insert("namespace".into(), text(label)); + } else { + data.remove("namespace"); + } + self.put(&key, &host_parent, Class::Inventory, data)?; + } + let csv=self.query("MATCH (n:OmRow {asset:$asset}) WHERE n.incarnation=$inc AND n.parent='csv-mappings' RETURN properties(n) AS p LIMIT 129",params([("asset",text(from.asset.to_string())),("inc",text(from.incarnation.to_string()))]))?; + if csv.len() > 128 { + return Err(Error::Limit); + } + for row in csv { + let key = string(&row, "key")?; + // Preserve the personal source's original target as a replay fence. + // It neither authorizes the retained identity nor creates a duplicate. + self.put(&key, "csv-mappings", Class::Checkpoint, target_props(from))?; + } + for (family, field) in [ + (Family::Addresses, "current_addresses"), + (Family::Network, "network_evidence"), + ] { + let old_sources = self.fact_rows(&family.parent(from), 8)?; + if old_sources.len() + self.fact_rows(&family.parent(to), 8)?.len() > 8 { + return Err(Error::Limit); + } + for source in old_sources { + require(context, from, to, kind, field)?; + let old = string(&source, "key")?; + let name = renamed(mapping, &string(&source, "source")?); + let binding = source.get("binding").and_then(Value::as_str); + let new = family.key(to, &name, binding); + self.relocate_source( + Some(context), + Relocation { + from, + to, + old: &old, + new: &new, + name: &name, + binding, + family, + }, + )?; + } + let summaries = self.fact_rows(&family.parent(to), 8)?; + let count = summaries.iter().try_fold(0u64, |sum, row| { + sum.checked_add(number(row, "count")?).ok_or(Error::Limit) + })?; + if count + > if family == Family::Addresses { + 64 + } else { + current_facts::MAX_FACTS as u64 + } + { + return Err(Error::Limit); + } + if family == Family::Network { + let bytes = summaries.iter().try_fold(0u64, |sum, row| { + sum.checked_add(number(row, "bytes")?).ok_or(Error::Limit) + })?; + if bytes > current_facts::MAX_BYTES as u64 { + return Err(Error::Limit); + } + } + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/correction/undo.rs b/crates/omloom-store/src/disk/workspace/correction/undo.rs new file mode 100644 index 0000000..1d2249d --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/correction/undo.rs @@ -0,0 +1,225 @@ +use super::*; + +impl Store { + pub(super) fn undo_identity_correction( + &self, + context: &Context, + id: &str, + working: &api::WorkingContext, + now: u64, + ) -> Result { + id.parse::().map_err(|_| Error::Invalid)?; + let mut tx = self.begin()?; + let key = format!("correction/{id}"); + let row = tx.get(&key)?.ok_or(Error::Expired)?; + if string(&row, "owner")? != context.actor + || number(&row, "source_binding_revision")? != context.authority_revision + { + return Err(Error::Denied); + } + if row.get("retired") == Some(&Value::Bool(true)) { + drop(tx); + return self.identity_correction_receipt(context, id, working); + } + if number(&row, "expires")? <= now { + return Err(Error::Expired); + } + if number(&row, "revision")? != number(&tx.root, "revision")? { + return Err(Error::Conflict); + } + let input = + api::IdentityCorrection::decode(binary(&row, "query")?).map_err(|_| Error::Corrupt)?; + let inverse = deltas::Capture::decode(binary(&row, "payload")?)?; + for mapping in &input.mappings { + let (from, to) = pair(mapping)?; + let original = inverse + .rows + .get(&asset_key(from)) + .and_then(Option::as_ref) + .ok_or(Error::Corrupt)?; + let kind = number(original, "kind")? as i32; + for target in [from, to] { + if !context.readable(target, kind) { + return Err(Error::Denied); + } + for field in ["identity_correction", "relations", "remove"] { + if !context.field(target, kind, field, true) { + return Err(Error::Denied); + } + } + for choice in &mapping.fields { + if !context.field(target, kind, &choice.name, true) + || !context.field(target, kind, &choice.name, false) + { + return Err(Error::Denied); + } + } + } + for (field, parent) in [ + ("identity_evidence", host_identity::parent(from)), + ( + "identity_evidence", + format!("{}/host-mappings", asset_key(from)), + ), + ( + "current_addresses", + source_layout::Family::Addresses.parent(from), + ), + ( + "network_evidence", + source_layout::Family::Network.parent(from), + ), + ] { + if inverse + .rows + .values() + .flatten() + .any(|p| p.get("parent") == Some(&text(&parent))) + { + for target in [from, to] { + if !context.field(target, kind, field, false) + || !context.field(target, kind, field, true) + { + return Err(Error::Denied); + } + } + } + } + let fence = tx + .get(&format!("removed/{}", from.asset))? + .ok_or(Error::Conflict)?; + if super::super::super::target(&fence)? != from + || string(&fence, "to_asset")? != to.asset.to_string() + || string(&fence, "to_incarnation")? != to.incarnation.to_string() + { + return Err(Error::Conflict); + } + tx.correction_guard(context, to, &api::WorkingContext::default())?; + } + for before in inverse.rows.values().flatten() { + if string(before, "parent")?.ends_with("/sockets") { + let bind = api::SocketBind::decode(binary(before, "payload")?) + .map_err(|_| Error::Corrupt)?; + let service = parse_target(bind.service.as_ref())?; + for write in [false, true] { + if !context.field( + service, + wire::AssetKind::Service as i32, + "network_evidence", + write, + ) { + return Err(Error::Denied); + } + } + } + } + for item in inverse + .rows + .keys() + .filter_map(|key| key.strip_prefix("relation/")) + { + tx.unlink_correction_relation(item)?; + } + for (key, before) in &inverse.rows { + if key.starts_with("relation/") { + continue; + } + match before { + None => tx.delete(key)?, + Some(before) => { + let parent = string(before, "parent")?; + let class = match number(before, "class")? { + 0 => Class::Inventory, + 5 => Class::Checkpoint, + _ => return Err(Error::Corrupt), + }; + let mut data = strip(before.clone()); + if parent == "assets" { + let prior = number(before, "revision")?; + let revision = match tx.get(key)? { + Some(current) => { + number(¤t, "revision")?.max(prior).checked_add(1) + } + None => prior.checked_add(2), + } + .ok_or(Error::Limit)?; + data.insert("revision".into(), int(revision)?); + } + tx.put(key, &parent, class, data)?; + } + } + } + for (key, before) in &inverse.rows { + if !key.starts_with("relation/") { + continue; + } + let Some(before) = before else { + tx.delete(key)?; + continue; + }; + let original = inventory::relation_projection(before)?; + let current = tx.get(key)?; + let expected = if let Some(current) = current { + number(¤t, "revision")? + } else { + tx.put(key, "relations", Class::Inventory, strip(before.clone()))?; + original.revision + }; + let mut restored = original.clone(); + restored.revision = expected.checked_add(1).ok_or(Error::Limit)?; + tx.save_rebound_relation(&restored, expected, &context.actor, now)?; + tx.rebind_correction_responsibility(context, &original, &restored)?; + } + // Validate the fully restored topology, including retired roots, before + // committing. A partial restoration is never exposed to another reader. + let selection = tx.selection(context, working, false)?; + for mapping in &input.mappings { + let (from, to) = pair(mapping)?; + for target in [from, to] { + tx.visible_asset(context, target)?; + selection.contains(target, &tx)?; + } + } + for (key, before) in &inverse.rows { + if key.starts_with("relation/") + && let Some(before) = before + { + tx.check_relationship( + context, + &selection, + &inventory::relation_projection(before)?, + )?; + } + } + // Reversal has its own audit event. Drop bulky inverse custody now; + // the small receipt is sufficient for an exact retry until cleanup. + let receipt = api::IdentityCorrectionApplied { + revision: number(&tx.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?, + id: id.into(), + undo_until: 0, + retained: input + .mappings + .into_iter() + .filter_map(|v| v.duplicate) + .collect(), + }; + tx.put( + &key, + "detail/identity-corrections", + Class::Detail, + props([ + ("owner", text(&context.actor)), + ("at", int(now)?), + ("expires", int(now)?), + ("source_binding_revision", int(context.authority_revision)?), + ("retired", Value::Bool(true)), + ("payload", Value::Binary(receipt.encode_to_vec())), + ]), + )?; + let revision = tx.commit()?; + debug_assert_eq!(revision, receipt.revision); + Ok(reply(api::reply::Result::IdentityCorrected(receipt))) + } +} diff --git a/crates/omloom-store/src/disk/workspace/current_facts.rs b/crates/omloom-store/src/disk/workspace/current_facts.rs index 8ab2066..24dd994 100644 --- a/crates/omloom-store/src/disk/workspace/current_facts.rs +++ b/crates/omloom-store/src/disk/workspace/current_facts.rs @@ -17,6 +17,9 @@ pub(super) fn source_key(parent: &str, source: &str) -> String { // reattesting a value changes only its small freshness row, never its payload. impl Tx<'_> { pub(in crate::disk) fn retire_current_facts(&mut self, target: AssetRef) -> Result<()> { + for row in self.fact_rows(&super::host_identity::parent(target), 32)? { + self.delete(&string(&row, "key")?)?; + } for row in self.fact_rows( &format!("{}/host-mappings", asset_key(target)), MAX_CHILDREN, diff --git a/crates/omloom-store/src/disk/workspace/exchange.rs b/crates/omloom-store/src/disk/workspace/exchange.rs index 5591cde..92585ce 100644 --- a/crates/omloom-store/src/disk/workspace/exchange.rs +++ b/crates/omloom-store/src/disk/workspace/exchange.rs @@ -236,8 +236,8 @@ impl Store { Ok((external, change)) })(); match parsed { - Ok((external, change)) => rows.push((external, api::ImportRow { row: row_number, change: Some(change), diagnostic: String::new() })), - Err(error) => rows.push((String::new(), api::ImportRow { row: row_number, change: None, diagnostic: format!("Row {row_number}, {diagnostic}: {error}. Review the mapping, declared type and current scope.") })), + Ok((external, change)) => rows.push((external, api::ImportRow { row: row_number, change: Some(change), diagnostic: String::new(), addresses: None })), + Err(error) => rows.push((String::new(), api::ImportRow { row: row_number, change: None, diagnostic: format!("Row {row_number}, {diagnostic}: {error}. Review the mapping, declared type and current scope."), addresses: None })), } } if rows.is_empty() { diff --git a/crates/omloom-store/src/disk/workspace/host_identity.rs b/crates/omloom-store/src/disk/workspace/host_identity.rs new file mode 100644 index 0000000..ff56825 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/host_identity.rs @@ -0,0 +1,490 @@ +//! Current layer-specific source claims. Equality supplies comparison evidence, +//! never an identity decision, a grant or permission to retarget an operation. +use super::*; + +const MAX_CLAIMS: usize = 4; +const FIELD: &str = "identity_evidence"; +fn lookup(claim: &api::HostIdentityClaim) -> String { + format!("{}/{}/{}", claim.kind, claim.namespace, claim.digest) +} +pub(in crate::disk) fn upgrade(store: &Store) -> Result<()> { + let mut after = String::new(); + loop { + let mut tx = store.begin()?; + let rows=tx.query("MATCH (n:OmRow {class:0}) WHERE n.key>$after AND n.parent ENDS WITH '/identity-evidence' RETURN properties(n) AS p ORDER BY n.key LIMIT 128",params([("after",text(&after))]))?; + if rows.is_empty() { + tx.commit()?; + return Ok(()); + } + for old in rows { + let key = string(&old, "key")?; + let parent = string(&old, "parent")?; + let claim = api::HostIdentityClaim::decode(binary(&old, "payload")?) + .map_err(|_| Error::Corrupt)?; + let mut data = old.clone(); + operations::strip_envelope(&mut data); + data.insert("claim".into(), text(lookup(&claim))); + tx.put_projection(&key, &parent, Class::Inventory, data, Some(&old))?; + after = key; + } + tx.commit()?; + } +} + +pub(super) fn parent(target: AssetRef) -> String { + format!( + "{}/{}/identity-evidence", + asset_key(target), + target.incarnation + ) +} + +pub(super) struct Source<'a> { + pub mapping: &'a str, + pub name: &'a str, + pub observed: u64, + pub boot: &'a str, +} + +pub(super) fn validate(node: &api::HostFinding) -> Result<()> { + use api::HostIdentityKind as I; + use wire::AssetKind as K; + if node.identity.len() > MAX_CLAIMS { + return Err(Error::Limit); + } + let mut keys = BTreeSet::new(); + for claim in &node.identity { + let kind = I::try_from(claim.kind).map_err(|_| Error::Invalid)?; + let subject = K::try_from(node.kind).map_err(|_| Error::Invalid)?; + let applicable = match kind { + I::HardwareUuid => { + matches!(subject, K::Device | K::VirtualMachine) + || (subject == K::Unspecified && node.key == "hardware") + } + I::InstallationId => subject == K::SoftwareInstallation, + I::InterfaceId | I::FactoryMac => subject == K::Interface, + I::Unspecified => false, + }; + if !applicable + || claim.namespace.is_empty() + || claim.namespace.len() > 80 + || !claim + .namespace + .bytes() + .all(|v| v.is_ascii_alphanumeric() || b"/-_.".contains(&v)) + || claim.digest.len() != 64 + || !claim + .digest + .bytes() + .all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v)) + || !matches!( + api::HostIdentityQuality::try_from(claim.quality), + Ok(api::HostIdentityQuality::Reported + | api::HostIdentityQuality::Movable + | api::HostIdentityQuality::Unreliable) + ) + || !keys.insert((claim.kind, claim.namespace.as_str())) + { + return Err(Error::Invalid); + } + } + Ok(()) +} + +impl Tx<'_> { + pub(super) fn accept_host_identity( + &mut self, + context: &Context, + target: AssetRef, + kind: i32, + source: Source<'_>, + claims: &[api::HostIdentityClaim], + ) -> Result<()> { + // An empty old-format report supplies no statement about identity. + // It cannot erase claims collected by a newer, selected source profile. + if claims.is_empty() { + return Ok(()); + } + if !context.field(target, kind, FIELD, true) { + return Err(Error::Denied); + } + let parent = parent(target); + let rows = self.fact_rows(&parent, MAX_CLAIMS * current_facts::MAX_SOURCES)?; + let mut count = rows.len(); + for claim in claims { + let slot = format!("{}/{}/{}", source.mapping, claim.kind, claim.namespace); + let key = format!("host-identity/{}", current_facts::digest(slot.as_bytes())); + let old = self.get(&key)?; + let payload = claim.encode_to_vec(); + if let Some(old) = &old { + let observed = number(old, "observed")?; + if observed > source.observed + || (observed == source.observed && binary(old, "payload")? != payload) + { + return Err(Error::Conflict); + } + } + if old.is_none() { + if count == MAX_CLAIMS * current_facts::MAX_SOURCES { + return Err(Error::Limit); + } + count += 1; + } + let mut data = target_props(target); + data.extend(props([ + ("source", text(source.name)), + ("binding", text(source.mapping)), + ("observed", int(source.observed)?), + ("vtext", text(source.boot)), + ("payload", Value::Binary(payload)), + ("claim", text(lookup(claim))), + ])); + self.put_projection(&key, &parent, Class::Inventory, data, old.as_ref())?; + } + Ok(()) + } + + pub(super) fn host_identity( + &self, + context: &Context, + target: AssetRef, + kind: i32, + ) -> Result> { + if !context.field(target, kind, FIELD, false) { + return Ok(Vec::new()); + } + self.fact_rows(&parent(target), MAX_CLAIMS * current_facts::MAX_SOURCES)? + .into_iter() + .map(|row| { + Ok(api::HostIdentityObservation { + claim: Some( + api::HostIdentityClaim::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?, + ), + source: string(&row, "source")?, + observed_at: number(&row, "observed")?, + boot_basis: string(&row, "vtext")?, + }) + }) + .collect() + } + + fn identity_subject( + &self, + context: &Context, + target: AssetRef, + ) -> Result { + let row = self.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + if !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device + | wire::AssetKind::VirtualMachine + | wire::AssetKind::SoftwareInstallation + | wire::AssetKind::Interface) + ) { + return Err(Error::Invalid); + } + let rows = self.query( + "MATCH (n:OmRow {parent:$parent}) WHERE n.layer='intent' RETURN properties(n) AS p LIMIT 33", + params([("parent", text(asset_key(target)))]), + )?; + if rows.len() > MAX_FIELDS { + return Err(Error::Limit); + } + let mut fields = Vec::with_capacity(rows.len()); + for row in rows { + let name = string(&row, "field")?; + if context.field(target, kind, &name, false) { + fields.push(inventory::read_field(&row)?); + } + } + Ok(api::HostIdentitySubject { + item: Some(api::Item { + target: Some(target_wire(target)), + kind, + revision: number(&row, "revision")?, + fields, + }), + identity: self.host_identity(context, target, kind)?, + sources: Vec::new(), + relations: Vec::new(), + }) + } +} + +impl Store { + pub(super) fn workspace_resolve_identity( + &self, + context: &Context, + request: api::ResolveIdentity, + working: &api::WorkingContext, + ) -> Result { + let original = parse_target(request.original.as_ref())?; + let mut current = original; + let tx = self.begin()?; + let mut seen = BTreeSet::new(); + for _ in 0..8 { + if !seen.insert(current) { + return Err(Error::Corrupt); + } + if let Some(row) = tx.get(&asset_key(current))? { + if super::super::target(&row)? != current { + return Err(Error::Conflict); + } + let mut selector = working.clone(); + for root in &mut selector.roots { + if root == &target_wire(original) { + *root = target_wire(current); + } + } + if selector.focus.as_ref() == Some(&target_wire(original)) { + selector.focus = Some(target_wire(current)); + } + tx.selection(context, &selector, false)? + .contains(current, &tx)?; + let item = tx.identity_subject(context, current)?.item; + if original != current + && !context.readable(original, item.as_ref().ok_or(Error::Corrupt)?.kind) + { + return Err(Error::Denied); + } + tx.commit()?; + return Ok(reply(api::reply::Result::ResolvedIdentity( + api::ResolvedIdentity { + original: Some(target_wire(original)), + canonical: item, + }, + ))); + } + let fence = tx + .get(&format!("removed/{}", current.asset))? + .ok_or(Error::Missing)?; + if !fence.contains_key("to_asset") { + return Err(Error::Missing); + } + if super::super::target(&fence)? != current { + return Err(Error::Conflict); + } + current = AssetRef { + asset: string(&fence, "to_asset")? + .parse() + .map_err(|_| Error::Corrupt)?, + incarnation: string(&fence, "to_incarnation")? + .parse() + .map_err(|_| Error::Corrupt)?, + }; + } + Err(Error::Limit) + } + pub(super) fn workspace_identity_candidates( + &self, + context: &Context, + request: api::FindIdentityCandidates, + working: &api::WorkingContext, + ) -> Result { + let tx = self.begin()?; + let selection = tx.selection(context, working, false)?; + let (target, kind, claims) = if let Some(reference) = &request.target { + if !request.claims.is_empty() || request.kind != 0 { + return Err(Error::Invalid); + } + let target = parse_target(Some(reference))?; + selection.contains(target, &tx)?; + let row = tx.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + ( + Some(target), + kind, + tx.host_identity(context, target, kind)? + .into_iter() + .filter_map(|v| v.claim) + .collect::>(), + ) + } else { + if request.claims.is_empty() + || !matches!( + wire::AssetKind::try_from(request.kind), + Ok(wire::AssetKind::Device + | wire::AssetKind::VirtualMachine + | wire::AssetKind::SoftwareInstallation + | wire::AssetKind::Interface) + ) + { + return Err(Error::Invalid); + } + validate(&api::HostFinding { + kind: request.kind, + identity: request.claims.clone(), + ..Default::default() + })?; + (None, request.kind, request.claims) + }; + if claims.is_empty() { + tx.commit()?; + return Ok(reply(api::reply::Result::IdentityCandidates( + api::IdentityCandidates { items: Vec::new() }, + ))); + } + let mut values = HashMap::from([ + ( + "claims".into(), + Value::Array(claims.iter().map(|v| text(lookup(v))).collect()), + ), + ( + "excluded".into(), + Value::Array(target.into_iter().map(|v| text(asset_key(v))).collect()), + ), + ("kind".into(), Value::Int(kind as i64)), + ( + "adopted".into(), + Value::Int(wire::Adoption::Adopted as i32 as i64), + ), + ]); + let visible = assets::visible_clause_for(context, Some(FIELD), &mut values, "a"); + let readable = assets::visible_clause_for(context, None, &mut values, "a"); + let members = if let Some(focus) = selection.focus { + let mut members = BTreeSet::from([focus]); + members.extend(tx.local_children(context, focus)?); + Some(members) + } else if !working.roots.is_empty() { + Some(tx.working_scope(context, &working.roots)?) + } else { + None + }; + let scope = if let Some(members) = members { + values.insert( + "members".into(), + Value::Array(members.into_iter().map(|t| text(asset_key(t))).collect()), + ); + "a.key IN $members" + } else { + "true" + }; + let rows=tx.query(&format!("UNWIND $claims AS claim MATCH (n:OmRow {{claim:claim}}) MATCH (a:OmRow {{key:'asset/'+n.asset}}) WHERE NOT a.key IN $excluded AND a.kind=$kind AND a.incarnation=n.incarnation AND n.parent=a.key+'/'+a.incarnation+'/identity-evidence' AND a.adoption=$adopted AND {visible} AND {readable} AND {scope} RETURN DISTINCT properties(a) AS p LIMIT 129"),Some(values))?; + if rows.len() > 128 { + return Err(Error::Limit); + } + let mut items = Vec::new(); + for row in rows { + let candidate = super::super::target(&row)?; + match selection.contains(candidate, &tx) { + Ok(()) => {} + Err(Error::Denied) => continue, + Err(error) => return Err(error), + } + let item = tx + .identity_subject(context, candidate)? + .item + .ok_or(Error::Corrupt)?; + if items.len() == 25 { + return Err(Error::Limit); + } + items.push(item); + } + tx.commit()?; + Ok(reply(api::reply::Result::IdentityCandidates( + api::IdentityCandidates { items }, + ))) + } + pub(super) fn workspace_compare_host_identity( + &self, + context: &Context, + value: api::CompareHostIdentity, + working: &api::WorkingContext, + ) -> Result { + let left = parse_target(value.left.as_ref())?; + let right = parse_target(value.right.as_ref())?; + if left == right { + return Err(Error::Invalid); + } + let tx = self.begin()?; + let revision = number(&tx.root, "revision")?; + let selection = tx.selection(context, working, false)?; + selection.contains(left, &tx)?; + selection.contains(right, &tx)?; + let mut comparison = api::HostIdentityComparison { + revision, + left: Some(tx.identity_subject(context, left)?), + right: Some(tx.identity_subject(context, right)?), + }; + if value.include_relations { + for subject in [&mut comparison.left, &mut comparison.right] + .into_iter() + .flatten() + { + let item = subject.item.as_ref().ok_or(Error::Corrupt)?; + let target = parse_target(item.target.as_ref())?; + let mut sources = BTreeSet::new(); + if context.field(target, item.kind, "identity_evidence", false) { + for row in tx.fact_rows(&format!("{}/host-mappings", asset_key(target)), 128)? { + sources.insert( + row.get("namespace") + .and_then(Value::as_str) + .unwrap_or(&string(&row, "source")?) + .to_owned(), + ); + } + } + for (family, field) in [ + ("address-sources", "current_addresses"), + ("network-sources", "network_evidence"), + ] { + if context.field(target, item.kind, field, false) { + for row in tx.fact_rows( + &format!("{}/{}/{family}", asset_key(target), target.incarnation), + 8, + )? { + sources.insert(string(&row, "source")?); + } + } + } + subject.sources = sources + .iter() + .filter(|source| { + !sources + .iter() + .any(|base| source.starts_with(&format!("{base} / "))) + }) + .cloned() + .collect(); + if context.field(target, item.kind, "relations", false) { + for relation in tx.working_relations(target)? { + let from = parse_target(relation.from.as_ref())?; + let to = parse_target(relation.to.as_ref())?; + let a = tx.asset(from)?; + let b = tx.asset(to)?; + if !context.field(from, number(&a, "kind")? as i32, "relations", false) + || !context.field(to, number(&b, "kind")? as i32, "relations", false) + || !context.readable(from, number(&a, "kind")? as i32) + || !context.readable(to, number(&b, "kind")? as i32) + { + continue; + } + let mut in_scope = true; + for endpoint in [from, to] { + match selection.contains(endpoint, &tx) { + Ok(()) => {} + Err(Error::Denied) => { + in_scope = false; + break; + } + Err(error) => return Err(error), + } + } + if in_scope { + subject.relations.push(relation); + } + } + } + } + } + tx.commit()?; + if self.usage()?.revision != revision { + return Err(Error::Expired); + } + Ok(reply(api::reply::Result::HostIdentityComparison( + comparison, + ))) + } +} diff --git a/crates/omloom-store/src/disk/workspace/host_import.rs b/crates/omloom-store/src/disk/workspace/host_import.rs index b33a20c..fe212ba 100644 --- a/crates/omloom-store/src/disk/workspace/host_import.rs +++ b/crates/omloom-store/src/disk/workspace/host_import.rs @@ -14,6 +14,14 @@ fn mapping(actor: &str, source: &str, basis: &str, key: &str) -> String { } format!("host-mapping/{:x}", hash.finalize()) } +fn network_suffix(part: i32) -> Result<&'static str> { + match api::HostNetworkPart::try_from(part).map_err(|_| Error::Invalid)? { + api::HostNetworkPart::Sockets => Ok("sockets"), + api::HostNetworkPart::Routes => Ok("routes"), + api::HostNetworkPart::ApplicationFirewall => Ok("application-fw"), + _ => Err(Error::Invalid), + } +} fn validate(report: &api::HostReport) -> Result<()> { if report.version != 1 || !matches!(report.platform.as_str(), "macos" | "linux" | "windows") @@ -34,6 +42,7 @@ fn validate(report: &api::HostReport) -> Result<()> { } let mut keys = BTreeMap::new(); for node in &report.findings { + super::host_identity::validate(node)?; if node.key.is_empty() || node.key.len() > 256 || node.key.chars().any(char::is_control) @@ -187,6 +196,12 @@ impl Tx<'_> { } } let prior = old.as_ref().map(super::super::target).transpose()?; + let projection_source = old + .as_ref() + .and_then(|row| row.get("namespace")) + .and_then(Value::as_str) + .unwrap_or(&request.source) + .to_owned(); let declared_kind = explicit.kind; if declared_kind.is_some() && (node.kind != wire::AssetKind::Unspecified as i32 || explicit.target.is_some()) @@ -311,20 +326,54 @@ impl Tx<'_> { )?; } } + let mut addresses = None; if node.kind == wire::AssetKind::Interface as i32 { if !context.field(target, node.kind, "current_addresses", true) { return Err(Error::Denied); } - self.accept_addresses(&api::InterfaceAddresses { - interface: Some(target_wire(target)), - source: request.source.clone(), - observed_at: report.observed_at, - addresses: node.addresses.clone(), - complete: node.addresses_complete, - })?; + addresses = self.accept_native_addresses( + context, + &api::InterfaceAddresses { + interface: Some(target_wire(target)), + source: projection_source.clone(), + observed_at: report.observed_at, + addresses: node.addresses.clone(), + complete: node.addresses_complete, + }, + &key, + )?; + } + if node.key == "installation" && report.network.is_some() { + // Legacy provenance must match the previous mapping window, + // before this report advances the checkpoint below. + for part in &request.network_parts { + let suffix = network_suffix(*part)?; + self.native_source( + target, + &format!("{projection_source} / {suffix}"), + &format!("{key}/{suffix}"), + &key, + source_layout::Family::Network, + )?; + } } + self.accept_host_identity( + context, + target, + kind, + super::host_identity::Source { + mapping: &key, + name: &projection_source, + observed: report.observed_at, + boot: &report.boot_basis, + }, + &node.identity, + )?; let mut data = target_props(target); data.insert("source".into(), text(&request.source)); + if projection_source != request.source { + data.insert("namespace".into(), text(&projection_source)); + } // One current checkpoint fences stale replay and same-instant // equivocation; it does not retain another report or field history. data.insert("observed".into(), int(report.observed_at)?); @@ -341,6 +390,7 @@ impl Tx<'_> { rows.push(api::ImportRow { row: rows.len() as u32 + 1, change: Some(change), + addresses, diagnostic: if keep_fields { "Keep previously accepted identity and local fields" } else if existing.is_some() { @@ -354,6 +404,18 @@ impl Tx<'_> { if let Some(installation) = resolved.get("installation") && let Some(source) = &report.network { + let metadata = self + .get(&mapping( + &context.actor, + &request.source, + &report.source_basis, + "installation", + ))? + .ok_or(Error::Corrupt)?; + let projection_source = metadata + .get("namespace") + .and_then(Value::as_str) + .unwrap_or(&request.source); let mut network = source.clone(); network.installation = Some(target_wire(*installation)); network.observed_at = report.observed_at; @@ -397,13 +459,8 @@ impl Tx<'_> { for part in &request.network_parts { let mut selected = network.clone(); let part = api::HostNetworkPart::try_from(*part).map_err(|_| Error::Invalid)?; - let suffix = match part { - api::HostNetworkPart::Sockets => "sockets", - api::HostNetworkPart::Routes => "routes", - api::HostNetworkPart::ApplicationFirewall => "application-fw", - _ => return Err(Error::Invalid), - }; - selected.source = format!("{} / {suffix}", request.source); + let suffix = network_suffix(part as i32)?; + selected.source = format!("{projection_source} / {suffix}"); if part != api::HostNetworkPart::Sockets { selected.sockets.clear(); selected.sockets_complete = false; @@ -427,7 +484,13 @@ impl Tx<'_> { "Not selected for this projection source".into(); } network_facts::validate(&selected)?; - self.accept_network(&selected)?; + let base = mapping( + &context.actor, + &request.source, + &report.source_basis, + "installation", + ); + self.accept_native_network(&selected, &format!("{base}/{suffix}"))?; } } Ok(rows) @@ -565,8 +628,14 @@ impl Store { && number(&row, "revision")? == base && number(&row, "expires")? > now { - let preview = - api::Preview::decode(binary(&row, "query")?).map_err(|_| Error::Corrupt)?; + // Reuse custody, not an old disclosure decision. The dry run + // above derived these rows under the caller's current rights. + let key = string(&row, "key")?; + let preview = api::Preview { + id: Some(key.strip_prefix("preview/").ok_or(Error::Corrupt)?.into()), + revision: base, + rows, + }; return Ok(reply(api::reply::Result::Preview(preview))); } let key = string(&row, "key")?; @@ -598,7 +667,6 @@ impl Store { ("working", Value::Binary(selector.encode_to_vec())), ("digest", Value::Binary(fingerprint.into_bytes())), ("payload", Value::Binary(bytes)), - ("query", Value::Binary(preview.encode_to_vec())), ]), )?; tx.commit()?; diff --git a/crates/omloom-store/src/disk/workspace/interface_facts.rs b/crates/omloom-store/src/disk/workspace/interface_facts.rs index e30a244..442582c 100644 --- a/crates/omloom-store/src/disk/workspace/interface_facts.rs +++ b/crates/omloom-store/src/disk/workspace/interface_facts.rs @@ -42,10 +42,89 @@ pub(super) fn validate_address(value: &api::CurrentAddress) -> Result<()> { } impl Tx<'_> { + pub(super) fn native_address_delta( + &self, + context: &Context, + value: &api::InterfaceAddresses, + binding: &str, + ) -> Result> { + self.address_delta_owned( + context, + parse_target(value.interface.as_ref())?, + &value.source, + &value.addresses, + value.complete, + Some(binding), + ) + } + fn address_delta_owned( + &self, + context: &Context, + target: AssetRef, + source: &str, + incoming: &[api::CurrentAddress], + complete: bool, + binding: Option<&str>, + ) -> Result> { + if !context.field(target, wire::AssetKind::Interface as i32, FIELD, false) { + return Ok(None); + } + let key = source_layout::Family::Addresses.key(target, source, binding); + let mut prior = BTreeMap::new(); + for row in self.fact_rows(&format!("{key}/values"), LIMIT)? { + let value = api::CurrentAddress::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + prior.insert(value.address.clone(), value); + } + let mut delta = api::AddressDelta { + source: source.into(), + complete, + ..Default::default() + }; + for value in incoming { + match prior.remove(&value.address) { + Some(old) if old != *value => delta.updated.push(api::AddressAttributeChange { + before: Some(old), + after: Some(value.clone()), + }), + Some(_) => {} + None => delta.added.push(value.clone()), + } + } + if complete { + delta.removed.extend(prior.into_values()); + } + Ok(Some(delta)) + } + pub(super) fn accept_addresses(&mut self, value: &api::InterfaceAddresses) -> Result<()> { + self.accept_addresses_owned(value, None) + } + pub(super) fn accept_native_addresses( + &mut self, + context: &Context, + value: &api::InterfaceAddresses, + binding: &str, + ) -> Result> { + self.native_source( + parse_target(value.interface.as_ref())?, + &value.source, + binding, + binding, + source_layout::Family::Addresses, + )?; + let delta = self.native_address_delta(context, value, binding)?; + self.accept_addresses_owned(value, Some(binding))?; + Ok(delta) + } + fn accept_addresses_owned( + &mut self, + value: &api::InterfaceAddresses, + binding: Option<&str>, + ) -> Result<()> { let target = parse_target(value.interface.as_ref())?; let parent = source_parent(target); - let key = source_key(target, &value.source); + let key = source_layout::Family::Addresses.key(target, &value.source, binding); let sources = self.fact_rows(&parent, current_facts::MAX_SOURCES)?; let old = self.get(&key)?; if old.is_none() && sources.len() >= current_facts::MAX_SOURCES { @@ -102,20 +181,18 @@ impl Tx<'_> { if other + count as u64 > LIMIT as u64 { return Err(Error::Limit); } - self.put_projection( - &key, - &parent, - Class::Inventory, - props([ - ("layer", text("address-source")), - ("source", text(&value.source)), - ("observed", int(value.observed_at)?), - ("vflag", Value::Bool(value.complete)), - ("count", int(count as u64)?), - ("digest", Value::Binary(digest)), - ]), - old.as_ref(), - )?; + let mut metadata = props([ + ("layer", text("address-source")), + ("source", text(&value.source)), + ("observed", int(value.observed_at)?), + ("vflag", Value::Bool(value.complete)), + ("count", int(count as u64)?), + ("digest", Value::Binary(digest)), + ]); + if let Some(binding) = binding { + metadata.insert("binding".into(), text(binding)); + } + self.put_projection(&key, &parent, Class::Inventory, metadata, old.as_ref())?; Ok(()) } @@ -154,6 +231,9 @@ impl Tx<'_> { .cmp(&b.value.as_ref().map(|a| &a.address)) }); result.push(api::InterfaceAddressView { + source_id: string(&meta, "key")?, + binding_unresolved: meta.get("source_revision") + == Some(&text("unresolved-native-binding")), coverage: Some(api::InterfaceAddresses { interface: Some(target_wire(target)), source: string(&meta, "source")?, diff --git a/crates/omloom-store/src/disk/workspace/network_facts.rs b/crates/omloom-store/src/disk/workspace/network_facts.rs index afa985c..21518e1 100644 --- a/crates/omloom-store/src/disk/workspace/network_facts.rs +++ b/crates/omloom-store/src/disk/workspace/network_facts.rs @@ -88,7 +88,7 @@ pub(super) fn validate(value: &api::HostNetwork) -> Result<()> { Ok(()) } -fn sections(value: &api::HostNetwork) -> Result<[BTreeMap; 4]> { +pub(super) fn sections(value: &api::HostNetwork) -> Result<[BTreeMap; 4]> { let mut sections: [BTreeMap; 4] = Default::default(); for s in &value.sockets { let identity = format!( @@ -176,9 +176,51 @@ pub(super) fn socket_layer(service: AssetRef) -> String { service.incarnation ) } + +pub(super) fn source_digest( + coverage: &api::HostNetwork, + sections: &[BTreeMap; 4], +) -> Result> { + let mut hash = Sha256::new(); + hash.update(b"omloom/network-evidence/1"); + for section in sections { + hash.update((section.len() as u32).to_le_bytes()); + for (id, data) in section { + hash.update((id.len() as u32).to_le_bytes()); + hash.update(id.as_bytes()); + let payload = binary(data, "payload")?; + hash.update((payload.len() as u32).to_le_bytes()); + hash.update(payload); + } + } + hash.update(coverage.encode_to_vec()); + Ok(hash.finalize().to_vec()) +} +pub(super) fn coverage(value: &api::HostNetwork) -> api::HostNetwork { + api::HostNetwork { + installation: value.installation.clone(), + source: value.source.clone(), + observed_at: value.observed_at, + basis: value.basis.clone(), + socket_coverage: value.socket_coverage.clone(), + route_coverage: value.route_coverage.clone(), + application_coverage: value.application_coverage.clone(), + packet_filter_coverage: value.packet_filter_coverage.clone(), + firewall_enabled: value.firewall_enabled, + block_all: value.block_all, + allow_builtin_signed: value.allow_builtin_signed, + allow_downloaded_signed: value.allow_downloaded_signed, + sockets_complete: value.sockets_complete, + routes_complete: value.routes_complete, + rules_complete: value.rules_complete, + ..Default::default() + } +} const PARTS: [&str; 4] = ["sockets", "routes", "rules", "policies"]; struct NetworkSource { + id: String, + binding_unresolved: bool, coverage: Option, observations: Vec, // None marks a known label already moved into the output page. @@ -239,6 +281,8 @@ impl Tx<'_> { .iter_mut() .map(|source| { let view = api::HostNetworkView { + source_id: source.id.clone(), + binding_unresolved: source.binding_unresolved, coverage: source.coverage.take(), ..Default::default() }; @@ -354,47 +398,31 @@ impl Tx<'_> { Ok(labels) } pub(super) fn accept_network(&mut self, value: &api::HostNetwork) -> Result<()> { + self.accept_network_owned(value, None) + } + pub(super) fn accept_native_network( + &mut self, + value: &api::HostNetwork, + binding: &str, + ) -> Result<()> { + self.accept_network_owned(value, Some(binding)) + } + fn accept_network_owned( + &mut self, + value: &api::HostNetwork, + binding: Option<&str>, + ) -> Result<()> { let target = parse_target(value.installation.as_ref())?; let parent = source_parent(target); - let key = current_facts::source_key(&parent, &value.source); + let key = source_layout::Family::Network.key(target, &value.source, binding); let sources = self.fact_rows(&parent, current_facts::MAX_SOURCES)?; let old = self.get(&key)?; if old.is_none() && sources.len() >= current_facts::MAX_SOURCES { return Err(Error::Limit); } let sections = sections(value)?; - let mut hash = Sha256::new(); - hash.update(b"omloom/network-evidence/1"); - let mut coverage = api::HostNetwork { - installation: value.installation.clone(), - source: value.source.clone(), - observed_at: value.observed_at, - basis: value.basis.clone(), - socket_coverage: value.socket_coverage.clone(), - route_coverage: value.route_coverage.clone(), - application_coverage: value.application_coverage.clone(), - packet_filter_coverage: value.packet_filter_coverage.clone(), - firewall_enabled: value.firewall_enabled, - block_all: value.block_all, - allow_builtin_signed: value.allow_builtin_signed, - allow_downloaded_signed: value.allow_downloaded_signed, - sockets_complete: value.sockets_complete, - routes_complete: value.routes_complete, - rules_complete: value.rules_complete, - ..Default::default() - }; - for section in §ions { - hash.update((section.len() as u32).to_le_bytes()); - for (id, props) in section { - hash.update((id.len() as u32).to_le_bytes()); - hash.update(id.as_bytes()); - let payload = binary(props, "payload")?; - hash.update((payload.len() as u32).to_le_bytes()); - hash.update(payload); - } - } - hash.update(coverage.encode_to_vec()); - let digest = hash.finalize().to_vec(); + let mut coverage = coverage(value); + let digest = source_digest(&coverage, §ions)?; if let Some(old) = &old { let observed = number(old, "observed")?; if observed > value.observed_at @@ -446,20 +474,18 @@ impl Tx<'_> { if count > current_facts::MAX_FACTS || bytes > current_facts::MAX_BYTES { return Err(Error::Limit); } - self.put_projection( - &key, - &parent, - Class::Inventory, - props([ - ("layer", text("network-source")), - ("source", text(&value.source)), - ("observed", int(value.observed_at)?), - ("count", int(own_count as u64)?), - ("bytes", int(own_bytes as u64)?), - ("digest", Value::Binary(digest)), - ]), - old.as_ref(), - )?; + let mut metadata = props([ + ("layer", text("network-source")), + ("source", text(&value.source)), + ("observed", int(value.observed_at)?), + ("count", int(own_count as u64)?), + ("bytes", int(own_bytes as u64)?), + ("digest", Value::Binary(digest)), + ]); + if let Some(binding) = binding { + metadata.insert("binding".into(), text(binding)); + } + self.put_projection(&key, &parent, Class::Inventory, metadata, old.as_ref())?; Ok(()) } @@ -580,6 +606,9 @@ impl Tx<'_> { coverage.packet_filter_coverage.clear(); } result.push(NetworkSource { + id: key, + binding_unresolved: source.get("source_revision") + == Some(&text("unresolved-native-binding")), coverage: Some(coverage), observations, services, diff --git a/crates/omloom-store/src/disk/workspace/responsibility.rs b/crates/omloom-store/src/disk/workspace/responsibility.rs index 71e445a..4805bf1 100644 --- a/crates/omloom-store/src/disk/workspace/responsibility.rs +++ b/crates/omloom-store/src/disk/workspace/responsibility.rs @@ -485,6 +485,44 @@ impl Tx<'_> { } Ok(kind) } + pub(super) fn placement_inheritance( + &self, + context: &Context, + target: AssetRef, + relation: &wire::Relation, + ) -> Result> { + let mut mask = 0; + for facet in [Facet::ClaimedOwner, Facet::Operator] { + if context.field( + target, + wire::AssetKind::VirtualMachine as i32, + field_name(facet)?, + false, + ) { + mask |= 1 << (facet as i32 - 1); + } + } + let graph = graph::Graph::load(self, [(target, mask)].into())?; + let mut affected = Vec::new(); + for facet in [Facet::ClaimedOwner, Facet::Operator] { + if mask & (1 << (facet as i32 - 1)) != 0 + && let Some(assignment) = graph.assignment(target, facet) + && assignment.mode == Mode::Inherit as i32 + && assignment.source_relation.as_deref() == Some(relation.relation_id.as_str()) + && assignment.source_revision == Some(relation.revision) + { + let mut value = graph.resolve(context, target, facet)?; + // Placement review needs the affected party, not its contact + // route or independently addressable source metadata. + value.contact = None; + value.source = None; + value.source_revision = None; + affected.push(value); + } + } + Ok(affected) + } + fn responsibility_state( &self, context: &Context, diff --git a/crates/omloom-store/src/disk/workspace/software.rs b/crates/omloom-store/src/disk/workspace/software.rs index 3e145e7..00237a0 100644 --- a/crates/omloom-store/src/disk/workspace/software.rs +++ b/crates/omloom-store/src/disk/workspace/software.rs @@ -288,7 +288,8 @@ impl Tx<'_> { ) -> Result<()> { use wire::RelationKind as R; if !matches!(kind, R::ReleaseOf | R::InstalledRelease) - && !(kind == R::RunsOn && matches!(from_kind, K::Container | K::Workload)) + && !(kind == R::RunsOn + && matches!(from_kind, K::Container | K::Workload | K::VirtualMachine)) { return Ok(()); } @@ -307,6 +308,7 @@ impl Tx<'_> { from_kind: K, to: AssetRef, kind: wire::RelationKind, + rebound: bool, ) -> Result<()> { use wire::RelationKind as R; let old_kind = R::try_from(number(old, "kind")? as i32).map_err(|_| Error::Corrupt)?; @@ -314,12 +316,21 @@ impl Tx<'_> { return Ok(()); } let old_from = stored_reference(old, "asset", "incarnation")?; - let container = old_kind == R::RunsOn - && if old_from == from { - from_kind == K::Container - } else { - number(&self.asset(old_from)?, "kind")? == K::Container as u64 - }; + let old_from_kind = if old_from == from { + from_kind + } else { + K::try_from(number(&self.asset(old_from)?, "kind")? as i32) + .map_err(|_| Error::Corrupt)? + }; + if old_kind == R::RunsOn && old_from_kind == K::VirtualMachine { + if !rebound && (from != old_from || kind != old_kind) { + return Err(Error::Conflict); + } + if from != old_from || kind != old_kind { + self.require_no_pending_operations(old_from)?; + } + } + let container = old_kind == R::RunsOn && old_from_kind == K::Container; if matches!(old_kind, R::ReleaseOf | R::InstalledRelease) || container { if from != old_from || kind != old_kind { return Err(Error::Conflict); diff --git a/crates/omloom-store/src/disk/workspace/source_layout.rs b/crates/omloom-store/src/disk/workspace/source_layout.rs new file mode 100644 index 0000000..970b99b --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/source_layout.rs @@ -0,0 +1,331 @@ +//! Source identity is a qualified binding, independent of its display label. +//! Relocation preserves individual observation windows and current-only state. +use super::*; +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Family { + Addresses, + Network, +} +impl Family { + pub fn name(self) -> &'static str { + match self { + Self::Addresses => "address-sources", + Self::Network => "network-sources", + } + } + fn parts(self) -> &'static [&'static str] { + match self { + Self::Addresses => &["values"], + Self::Network => &["sockets", "routes", "rules", "policies"], + } + } + pub fn parent(self, target: AssetRef) -> String { + format!( + "{}/{}/{}", + asset_key(target), + target.incarnation, + self.name() + ) + } + pub fn key(self, target: AssetRef, name: &str, binding: Option<&str>) -> String { + match binding { + Some(binding) => current_facts::source_key(&format!("native/{}", self.name()), binding), + None => current_facts::source_key(&self.parent(target), name), + } + } +} +pub(super) struct Relocation<'a> { + pub from: AssetRef, + pub to: AssetRef, + pub old: &'a str, + pub new: &'a str, + pub name: &'a str, + pub binding: Option<&'a str>, + pub family: Family, +} +fn data(mut row: Properties) -> Properties { + operations::strip_envelope(&mut row); + row +} + +impl Tx<'_> { + pub(super) fn native_source( + &mut self, + target: AssetRef, + name: &str, + binding: &str, + base_binding: &str, + family: Family, + ) -> Result { + let key = family.key(target, name, Some(binding)); + if let Some(current) = self.get(&key)? { + if string(¤t, "parent")? != family.parent(target) + || current.get("binding") != Some(&text(binding)) + { + return Err(Error::Conflict); + } + return Ok(key); + } + let legacy = family.key(target, name, None); + let Some(old) = self.get(&legacy)? else { + return Ok(key); + }; + let suffix = if family == Family::Network { + Some( + binding + .strip_prefix(&format!("{base_binding}/")) + .ok_or(Error::Invalid)?, + ) + } else { + None + }; + let mappings = self + .fact_rows(&format!("{}/host-mappings", asset_key(target)), 128)? + .into_iter() + .filter(|p| { + p.get("namespace") + .or_else(|| p.get("source")) + .and_then(Value::as_str) + .is_some_and(|label| match suffix { + Some(suffix) => name == format!("{label} / {suffix}"), + None => name == label, + }) + }) + .collect::>(); + if mappings.len() == 1 + && string(&mappings[0], "key")? == base_binding + && number(&mappings[0], "observed")? == number(&old, "observed")? + { + self.relocate_source( + None, + Relocation { + from: target, + to: target, + old: &legacy, + new: &key, + name, + binding: Some(binding), + family, + }, + )?; + } else if !mappings.is_empty() { + let mut marked = data(old); + marked.insert("source_revision".into(), text("unresolved-native-binding")); + self.put(&legacy, &family.parent(target), Class::Inventory, marked)?; + } + Ok(key) + } + pub(super) fn relocate_source( + &mut self, + context: Option<&Context>, + move_: Relocation<'_>, + ) -> Result<()> { + let Relocation { + from, + to, + old, + new, + name, + binding, + family, + } = move_; + let source = self.get(old)?.ok_or(Error::Missing)?; + if string(&source, "parent")? != family.parent(from) { + return Err(Error::Conflict); + } + if old != new && self.get(new)?.is_some() { + return Err(Error::Conflict); + } + if context.is_none() && (from != to || name != string(&source, "source")?) { + return Err(Error::Invalid); + } + let observed = number(&source, "observed")?; + let mut input = api::HostNetwork::default(); + if family == Family::Network { + let body = self + .get(&format!("{old}/coverage"))? + .ok_or(Error::Corrupt)?; + input = + api::HostNetwork::decode(binary(&body, "payload")?).map_err(|_| Error::Corrupt)?; + input.installation = Some(target_wire(to)); + input.source = name.into(); + input.observed_at = observed; + let mut coverage = network_facts::coverage(&input); + coverage.observed_at = 0; + self.put( + &format!("{new}/coverage"), + new, + Class::Inventory, + props([("payload", Value::Binary(coverage.encode_to_vec()))]), + )?; + if old != new { + self.delete(&format!("{old}/coverage"))?; + } + } + let mut addresses = Vec::new(); + for (index, part) in family.parts().iter().enumerate() { + let parent = format!("{new}/{part}"); + for mut row in self.fact_rows( + &format!("{old}/{part}"), + if family == Family::Addresses { + 64 + } else { + current_facts::MAX_FACTS + }, + )? { + let key = string(&row, "key")?; + let window = self + .get(&format!("{key}/freshness"))? + .ok_or(Error::Corrupt)?; + let latest = window.get("observed") == Some(&int(observed)?); + let identity = if family == Family::Addresses { + let value = api::CurrentAddress::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + let id = value.address.clone(); + if latest { + addresses.push(value); + } + id + } else { + let mut one = api::HostNetwork { + installation: Some(target_wire(to)), + ..Default::default() + }; + match index { + 0 => { + let value = api::SocketBind::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + if let Some(context) = context { + let service = parse_target(value.service.as_ref())?; + if !context.field( + service, + wire::AssetKind::Service as i32, + "network_evidence", + false, + ) || !context.field( + service, + wire::AssetKind::Service as i32, + "network_evidence", + true, + ) { + return Err(Error::Denied); + } + } + one.sockets.push(value.clone()); + if latest { + input.sockets.push(value); + } + row.extend(target_props(to)); + } + 1 => { + let value = api::LocalRoute::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + one.routes.push(value.clone()); + if latest { + input.routes.push(value); + } + } + 2 => { + let value = api::ApplicationRule::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + one.rules.push(value.clone()); + if latest { + input.rules.push(value); + } + } + 3 => { + let value = api::FirewallSetting::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + match api::firewall_setting::Kind::try_from(value.kind) { + Ok(api::firewall_setting::Kind::Enabled) => { + one.firewall_enabled = Some(value.enabled); + if latest { + input.firewall_enabled = Some(value.enabled); + } + } + Ok(api::firewall_setting::Kind::BlockAll) => { + one.block_all = Some(value.enabled); + if latest { + input.block_all = Some(value.enabled); + } + } + Ok(api::firewall_setting::Kind::AllowBuiltinSigned) => { + one.allow_builtin_signed = Some(value.enabled); + if latest { + input.allow_builtin_signed = Some(value.enabled); + } + } + Ok(api::firewall_setting::Kind::AllowDownloadedSigned) => { + one.allow_downloaded_signed = Some(value.enabled); + if latest { + input.allow_downloaded_signed = Some(value.enabled); + } + } + _ => return Err(Error::Corrupt), + } + } + _ => return Err(Error::Corrupt), + } + network_facts::sections(&one)?[index] + .keys() + .next() + .cloned() + .ok_or(Error::Corrupt)? + }; + let next = format!( + "fact/{}", + current_facts::digest(format!("{parent}/{identity}").as_bytes()) + ); + row = data(row); + if family == Family::Addresses { + row.insert("owner".into(), text(asset_key(to))); + row.insert("layer".into(), text(interface_facts::address_layer(to))); + } + self.put(&next, &parent, Class::Inventory, row)?; + self.put( + &format!("{next}/freshness"), + &next, + Class::Inventory, + data(window), + )?; + if next != key { + self.delete(&format!("{key}/freshness"))?; + self.delete(&key)?; + } + } + } + let mut source = data(source); + source.insert("source".into(), text(name)); + if let Some(binding) = binding { + source.insert("binding".into(), text(binding)); + } + let digest = if family == Family::Addresses { + addresses.sort_by(|a, b| a.address.cmp(&b.address)); + Sha256::digest( + api::InterfaceAddresses { + interface: Some(target_wire(to)), + source: name.into(), + observed_at: observed, + addresses, + complete: flag(&source, "vflag")?, + } + .encode_to_vec(), + ) + .to_vec() + } else { + network_facts::source_digest( + &network_facts::coverage(&input), + &network_facts::sections(&input)?, + )? + }; + source.insert("digest".into(), Value::Binary(digest)); + self.put(new, &family.parent(to), Class::Inventory, source)?; + if old != new { + self.delete(old)?; + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/source_layout/tests.rs b/crates/omloom-store/src/disk/workspace/source_layout/tests.rs new file mode 100644 index 0000000..57004a6 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/source_layout/tests.rs @@ -0,0 +1,270 @@ +use super::*; +type TestResult = std::result::Result<(), Box>; + +// An upgraded native import must qualify the previous source window before +// advancing its mapping, preserving omitted facts without a second source slot. +#[test] +fn newer_host_import_qualifies_legacy_network_before_checkpoint() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let context = Context { + actor: "test".into(), + fixture: "operator".into(), + grants: vec![Grant { + scope: Scope::All, + read: Fields::All, + write: Fields::All, + writable: true, + }], + teams: Vec::new(), + recipients: Vec::new(), + schema_admin: false, + authority_revision: 1, + }; + let mut request = api::HostImport { + source: "Collector".into(), + report: Some(Box::new(api::HostReport { + version: 1, + platform: "linux".into(), + source_basis: "a".repeat(64), + boot_basis: "b".repeat(64), + observed_at: 100, + findings: vec![api::HostFinding { + key: "installation".into(), + kind: wire::AssetKind::SoftwareInstallation as i32, + fields: vec![wire::Field { + name: "name".into(), + value: Some(wire::field::Value::Text("Synthetic OS".into())), + }], + ..Default::default() + }], + network: Some(api::HostNetwork { + route_coverage: "verified".into(), + routes_complete: true, + routes: vec![api::LocalRoute { + destination: "192.0.2.0/24".into(), + interface: "eth0".into(), + family: "IPv4".into(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + })), + selection: vec![api::HostSelection { + key: "installation".into(), + ..Default::default() + }], + network_parts: vec![api::HostNetworkPart::Routes as i32], + ..Default::default() + }; + let import = |request: api::HostImport| -> Result { + let working = api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }; + let preview = store.workspace_host_import(&context, request, &working, 100)?; + let Some(api::reply::Result::Preview(preview)) = preview.result else { + return Err(Error::Corrupt); + }; + store.workspace_host_import( + &context, + api::HostImport { + preview: preview.id, + ..Default::default() + }, + &api::WorkingContext { + expected: Some(store.usage()?.revision), + ..working + }, + 100, + ) + }; + import(request.clone())?; + let mut tx = store.begin()?; + let mappings = tx.query("MATCH (n:OmRow {source:'Collector'}) WHERE n.key STARTS WITH 'host-mapping/' RETURN properties(n) AS p", None)?; + assert_eq!(mappings.len(), 1); + let target = super::super::super::target(&mappings[0])?; + let name = "Collector / routes"; + let binding = format!("{}/routes", string(&mappings[0], "key")?); + let native = Family::Network.key(target, name, Some(&binding)); + let legacy = Family::Network.key(target, name, None); + tx.relocate_source( + None, + Relocation { + from: target, + to: target, + old: &native, + new: &legacy, + name, + binding: None, + family: Family::Network, + }, + )?; + let mut row = data(tx.get(&legacy)?.ok_or(Error::Missing)?); + row.remove("binding"); + tx.put( + &legacy, + &Family::Network.parent(target), + Class::Inventory, + row, + )?; + tx.commit()?; + let report = request.report.as_mut().ok_or(Error::Invalid)?; + report.observed_at = 200; + let network = report.network.as_mut().ok_or(Error::Invalid)?; + network.routes_complete = false; + network.routes[0].destination = "198.51.100.0/24".into(); + import(request)?; + let tx = store.begin()?; + assert!( + tx.get(&legacy)?.is_none(), + "proven legacy source must be relocated" + ); + assert_eq!(tx.fact_rows(&Family::Network.parent(target), 8)?.len(), 1); + let facts = tx.fact_rows(&format!("{native}/routes"), 64)?; + assert_eq!(facts.len(), 2); + let mut windows = Vec::new(); + for fact in facts { + let freshness = tx + .get(&format!("{}/freshness", string(&fact, "key")?))? + .ok_or(Error::Corrupt)?; + windows.push(number(&freshness, "observed")?); + } + windows.sort(); + assert_eq!(windows, vec![100, 200]); + Ok(()) +} +fn policy() -> Policy { + Policy { + classes: [crate::Budget { + rows: 10000, + bytes: 16 * 1024 * 1024, + }; CLASSES], + total_bytes: 128 * 1024 * 1024, + detail_age_ms: 1000, + review_age_ms: 500, + review_per_source: crate::Budget { + rows: 512, + bytes: 1024 * 1024, + }, + batch_rows: 128, + } +} + +// Qualifying a proven legacy binding preserves older omitted facts and their +// collection windows. Repeating the same partial input must remain a no-op. +#[test] +fn legacy_source_qualification_preserves_partial_windows_and_replay() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let target = new_target(); + let mut value = api::InterfaceAddresses { + interface: Some(target_wire(target)), + source: "Collector".into(), + observed_at: 100, + addresses: vec![api::CurrentAddress { + address: "192.0.2.1".into(), + stability: api::AddressStability::Stable as i32, + state: api::AddressState::Preferred as i32, + ..Default::default() + }], + complete: true, + }; + let binding = "host-mapping/proof"; + let mut tx = store.begin()?; + tx.accept_addresses(&value)?; + value.observed_at = 150; + value.complete = false; + value.addresses[0].address = "192.0.2.2".into(); + tx.accept_addresses(&value)?; + tx.put( + binding, + &format!("{}/host-mappings", asset_key(target)), + Class::Inventory, + props([("source", text(&value.source)), ("observed", int(150)?)]), + )?; + tx.commit()?; + let mut tx = store.begin()?; + let old = Family::Addresses.key(target, &value.source, None); + let key = tx.native_source(target, &value.source, binding, binding, Family::Addresses)?; + assert!(tx.get(&old)?.is_none()); + let facts = tx.fact_rows(&format!("{key}/values"), 64)?; + assert_eq!(facts.len(), 2); + let mut windows = Vec::new(); + for fact in facts { + let row = tx + .get(&format!("{}/freshness", string(&fact, "key")?))? + .ok_or(Error::Corrupt)?; + windows.push(number(&row, "observed")?); + } + windows.sort(); + assert_eq!(windows, vec![100, 150]); + tx.commit()?; + let context = Context { + actor: "test".into(), + fixture: "operator".into(), + grants: vec![Grant { + scope: Scope::All, + read: Fields::All, + write: Fields::All, + writable: true, + }], + teams: Vec::new(), + recipients: Vec::new(), + schema_admin: false, + authority_revision: 1, + }; + let before = store.usage()?; + let mut tx = store.begin()?; + tx.accept_native_addresses(&context, &value, binding)?; + tx.commit()?; + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Restartable claim backfill uses the regular accounting path and does not +// release a restore fence or alter observation freshness. +#[test] +fn identity_lookup_upgrade_preserves_restore_fence_and_evidence() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let target = new_target(); + let claim = api::HostIdentityClaim { + kind: api::HostIdentityKind::HardwareUuid as i32, + namespace: "smbios/system-uuid".into(), + digest: "a".repeat(64), + quality: api::HostIdentityQuality::Reported as i32, + }; + let mut tx = store.begin()?; + let mut data = target_props(target); + data.insert("payload".into(), Value::Binary(claim.encode_to_vec())); + data.insert("observed".into(), int(100)?); + tx.put( + "host-identity/legacy", + &host_identity::parent(target), + Class::Inventory, + data, + )?; + tx.root.insert("schema".into(), int(11)?); + tx.root.insert("restored".into(), Value::Bool(true)); + tx.changed = true; + tx.commit()?; + drop(store); + let store = Store::open(dir.path(), policy())?; + let tx = store.begin()?; + assert_eq!(tx.root.get("restored"), Some(&Value::Bool(true))); + assert_eq!(number(&tx.root, "schema")?, 12); + let row = tx.get("host-identity/legacy")?.ok_or(Error::Corrupt)?; + assert_eq!(number(&row, "observed")?, 100); + assert_eq!( + string(&row, "claim")?, + format!( + "{}/smbios/system-uuid/{}", + api::HostIdentityKind::HardwareUuid as i32, + "a".repeat(64) + ) + ); + tx.commit()?; + Ok(()) +} diff --git a/crates/omloom-store/src/disk/workspace/vm_placement.rs b/crates/omloom-store/src/disk/workspace/vm_placement.rs new file mode 100644 index 0000000..f31f7f2 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/vm_placement.rs @@ -0,0 +1,131 @@ +//! Placement review reuses conditional relationship mutation. No move preview, +//! address history or second authoritative inventory is persisted. +use super::*; + +impl Store { + pub(super) fn workspace_prepare_vm_placement( + &self, + context: &Context, + request: api::PrepareVmPlacement, + working: &api::WorkingContext, + ) -> Result { + let vm = parse_target(request.vm.as_ref())?; + let tx = self.begin()?; + let selection = tx.selection(context, working, false)?; + selection.contains(vm, &tx)?; + let item = tx.placement_item(context, vm)?; + if item.kind != wire::AssetKind::VirtualMachine as i32 { + return Err(Error::Invalid); + } + if !context.field(vm, item.kind, "relations", false) + || !context.field(vm, item.kind, "relations", true) + { + return Err(Error::Denied); + } + // Do not filter hidden placements into a false "unplaced" answer. + let rows = tx.query( + "MATCH (r:OmRow {asset:$asset}) WHERE r.parent='relations' AND r.incarnation=$inc AND r.kind=$kind RETURN properties(r) AS p LIMIT 2", + params([ + ("asset", text(vm.asset.to_string())), + ("inc", text(vm.incarnation.to_string())), + ("kind", int(wire::RelationKind::RunsOn as u64)?), + ]), + )?; + if rows.len() > 1 { + return Err(Error::Conflict); + } + let mut review = api::VmPlacementReview { + revision: number(&tx.root, "revision")?, + vm: Some(item), + ..Default::default() + }; + if let Some(row) = rows.first() { + let relation = inventory::relation_projection(row)?; + let (_, old_host) = tx.check_relationship(context, &selection, &relation)?; + let host = tx.placement_item(context, old_host)?; + if !context.field(old_host, host.kind, "relations", false) { + return Err(Error::Denied); + } + review.current_host = Some(host); + review.current = Some(relation); + } + if let Some(destination) = request.destination.as_ref() { + let destination = parse_target(Some(destination))?; + let host = tx.placement_item(context, destination)?; + if host.kind != wire::AssetKind::SoftwareInstallation as i32 { + return Err(Error::Invalid); + } + if !context.field(destination, host.kind, "virtualization_role", false) { + return Err(Error::Denied); + } + let row = tx.asset(destination)?; + if row.get("virtualization_role").and_then(Value::as_str) + != Some(api::VirtualizationRole::Hypervisor.as_str_name()) + { + return Err(Error::Invalid); + } + let proposed = wire::Relation { + from: Some(target_wire(vm)), + to: Some(target_wire(destination)), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }; + tx.check_relationship(context, &selection, &proposed)?; + let unchanged = review.current.as_ref().is_some_and(|r| r.to == proposed.to); + if !unchanged { + tx.require_no_pending_operations(vm)?; + let key = review + .current + .as_ref() + .map(|r| format!("relation/{}", r.relation_id)); + tx.check_placement_cycle(vm, destination, key.as_deref())?; + if let Some(current) = review.current.as_ref() { + review.invalidated_inheritance = + tx.placement_inheritance(context, vm, current)?; + } + } + review.destination = Some(host); + } + let hosts: BTreeSet<_> = review + .current_host + .iter() + .chain(review.destination.iter()) + .map(|item| parse_target(item.target.as_ref())) + .collect::>()?; + let rows = hosts + .into_iter() + .map(|host| tx.asset(host)) + .collect::>>()?; + review.contexts = tx.item_contexts( + context, + &rows, + &api::Query { + roots: working.roots.clone(), + ..Default::default() + }, + )?; + tx.commit()?; + Ok(reply(api::reply::Result::VmPlacementReview(Box::new( + review, + )))) + } +} + +impl Tx<'_> { + fn placement_item(&self, context: &Context, target: AssetRef) -> Result { + let row = self.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + let mut fields = Vec::new(); + if context.field(target, kind, "name", false) + && let Some(field) = self.get(&format!("{}/intent/name", asset_key(target)))? + { + fields.push(inventory::read_field(&field)?); + } + Ok(api::Item { + target: Some(target_wire(target)), + kind, + revision: number(&row, "revision")?, + fields, + }) + } +} diff --git a/crates/omloom-store/tests/disk.rs b/crates/omloom-store/tests/disk.rs index b963770..e99c984 100644 --- a/crates/omloom-store/tests/disk.rs +++ b/crates/omloom-store/tests/disk.rs @@ -702,11 +702,11 @@ fn typed_relations_validate_endpoints_and_reopen() -> TestResult { fn relation_moves_preserve_parallel_edges() -> TestResult { let dir = tempfile::tempdir()?; let store = Store::open(dir.path(), policy())?; - let mut vm = asset(); - vm.kind = omloom_contracts::wire::AssetKind::VirtualMachine as i32; + let mut service = asset(); + service.kind = omloom_contracts::wire::AssetKind::Service as i32; let first = asset(); let second = asset(); - for object in [&vm, &first, &second] { + for object in [&service, &first, &second] { store.save_asset( &admit(record::Body::Asset(object.clone())), None, @@ -716,9 +716,9 @@ fn relation_moves_preserve_parallel_edges() -> TestResult { } let mut relation = omloom_contracts::wire::Relation { relation_id: RelationId::generate().to_string(), - from: vm.target.clone(), + from: service.target.clone(), to: first.target.clone(), - kind: omloom_contracts::wire::RelationKind::RunsOn as i32, + kind: omloom_contracts::wire::RelationKind::DependsOn as i32, revision: 1, authority_id: AuthorityId::generate().to_string(), }; @@ -744,13 +744,16 @@ fn relation_moves_preserve_parallel_edges() -> TestResult { "admin", 4, )?; - assert_eq!(store.relations(target(&vm), 10)?.len(), 2); + assert_eq!(store.relations(target(&service), 10)?.len(), 2); assert_eq!(store.relations(target(&first), 10)?, vec![parallel]); assert_eq!(store.relations(target(&second), 10)?, vec![relation]); - assert!(matches!(store.relations(target(&vm), 1), Err(Error::Limit))); + assert!(matches!( + store.relations(target(&service), 1), + Err(Error::Limit) + )); drop(store); let store = Store::open(dir.path(), policy())?; - assert_eq!(store.relations(target(&vm), 10)?.len(), 2); + assert_eq!(store.relations(target(&service), 10)?.len(), 2); assert_eq!(store.relations(target(&first), 10)?.len(), 1); assert_eq!(store.relations(target(&second), 10)?.len(), 1); Ok(()) diff --git a/crates/omloom-store/tests/workspace.rs b/crates/omloom-store/tests/workspace.rs index d6922dc..b2ef18e 100644 --- a/crates/omloom-store/tests/workspace.rs +++ b/crates/omloom-store/tests/workspace.rs @@ -10,6 +10,8 @@ use support::*; mod accounts; #[path = "workspace/contexts.rs"] mod contexts; +#[path = "workspace/correction.rs"] +mod correction; #[path = "workspace/definitions.rs"] mod definitions; #[path = "workspace/exchange.rs"] @@ -28,6 +30,8 @@ mod responsibility; mod software; #[path = "workspace/views.rs"] mod views; +#[path = "workspace/vm_placement.rs"] +mod vm_placement; fn operator() -> Context { Context { diff --git a/crates/omloom-store/tests/workspace/contexts.rs b/crates/omloom-store/tests/workspace/contexts.rs index 25b0ee1..e8621a8 100644 --- a/crates/omloom-store/tests/workspace/contexts.rs +++ b/crates/omloom-store/tests/workspace/contexts.rs @@ -318,9 +318,10 @@ fn hypervisor_ownership_cycles_are_rejected_in_both_insertion_orders() -> TestRe Ok(()) } -// Shared acyclic execution ancestors are valid; a later edge cannot close a multi-installation cycle. +// A move between installations on the same hardware remains valid; a later +// placement replacement cannot close a multi-installation cycle. #[test] -fn shared_hypervisor_ancestors_are_valid_but_indirect_cycles_are_not() -> TestResult { +fn shared_hypervisor_ancestry_survives_moves_but_indirect_cycles_do_not() -> TestResult { let dir = tempfile::tempdir()?; let store = Store::open(dir.path(), policy())?; let actor = operator(); @@ -343,20 +344,37 @@ fn shared_hypervisor_ancestors_are_valid_but_indirect_cycles_are_not() -> TestRe )?)); } let guest = create(&store, &actor, K::VirtualMachine, "Shared guest")?; + let mut placement = wire::Relation { + from: Some(guest.clone()), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }; for host in &hosts { + placement.to = Some(host.clone()); call( &store, &actor, Command::Relation(api::Relationship { - relation: Some(wire::Relation { - from: Some(guest.clone()), - to: Some(host.clone()), - kind: wire::RelationKind::RunsOn as i32, - ..Default::default() - }), + relation: Some(placement.clone()), remove: false, }), )?; + let Reply::Detail(current) = call( + &store, + &actor, + Command::Read(api::Read { + target: Some(guest.clone()), + ..Default::default() + }), + )? + else { + return Err("detail".into()); + }; + placement = current + .relations + .into_iter() + .find(|r| r.kind == wire::RelationKind::RunsOn as i32) + .ok_or("placement")?; } let vm = create(&store, &actor, K::VirtualMachine, "Nested guest")?; let nested = changed(within( @@ -401,18 +419,14 @@ fn shared_hypervisor_ancestors_are_valid_but_indirect_cycles_are_not() -> TestRe }), )?); let before = store.usage()?; + placement.to = Some(inner); // Guest -> nested OS -> nested guest -> inner OS -> guest would close the cycle. assert!(matches!( call( &store, &actor, Command::Relation(api::Relationship { - relation: Some(wire::Relation { - from: Some(guest), - to: Some(inner), - kind: wire::RelationKind::RunsOn as i32, - ..Default::default() - }), + relation: Some(placement), remove: false }) ), diff --git a/crates/omloom-store/tests/workspace/correction.rs b/crates/omloom-store/tests/workspace/correction.rs new file mode 100644 index 0000000..375cfe5 --- /dev/null +++ b/crates/omloom-store/tests/workspace/correction.rs @@ -0,0 +1,551 @@ +use super::*; +use api::{reply::Result as Reply, request::Command}; +use omloom_store::Class; +#[path = "correction/continuity.rs"] +mod continuity; +#[path = "correction/safety.rs"] +mod safety; + +fn child( + store: &Store, + parent: &wire::AssetRef, + kind: K, + name: &str, +) -> Result { + let value = store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + focus: Some(parent.clone()), + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::Write(api::Write { + kind: kind as i32, + parent: Some(parent.clone()), + changes: vec![text_field("name", name)], + ..Default::default() + })), + }, + 100, + )?; + let Some(Reply::Mutation(value)) = value.result else { + return Err(Error::Corrupt); + }; + value.targets.into_iter().next().ok_or(Error::Corrupt) +} +fn mapping(from: &wire::AssetRef, to: &wire::AssetRef) -> api::IdentityMapping { + api::IdentityMapping { + duplicate: Some(from.clone()), + retained: Some(to.clone()), + fields: vec![api::IdentityFieldChoice { + name: "name".into(), + use_duplicate: false, + }], + ..Default::default() + } +} +fn prepare( + store: &Store, + mappings: Vec, + decision: api::IdentityDecision, +) -> Result { + let Some(Reply::IdentityCorrection(value)) = store + .workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + decision: decision as i32, + mappings, + ..Default::default() + })), + }, + 100, + )? + .result + else { + return Err(Error::Corrupt); + }; + Ok(value) +} +fn apply(store: &Store, id: &str) -> Result { + let Reply::IdentityCorrected(value) = call( + store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(id.into()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(value) +} +fn read(store: &Store, target: &wire::AssetRef) -> Result { + let Reply::Detail(value) = call( + store, + &operator(), + Command::Read(api::Read { + target: Some(target.clone()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) +} + +// Reversal must restore adjacency as well as the row of a redundant relation. +#[test] +fn undo_restores_deleted_relation_adjacency() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate")?; + let retained_os = child(&store, &retained, K::SoftwareInstallation, "Retained OS")?; + let os = child(&store, &duplicate, K::SoftwareInstallation, "OS")?; + let plan = prepare( + &store, + vec![mapping(&duplicate, &retained), mapping(&os, &retained_os)], + api::IdentityDecision::DuplicateInstallation, + )?; + let relation_id = read(&store, &os)? + .relations + .into_iter() + .find(|r| r.from.as_ref() == Some(&duplicate)) + .ok_or("relation before correction")? + .relation_id; + let applied = apply(&store, &plan.id)?; + assert!(matches!(read(&store, &os), Err(Error::Missing))); + assert!( + !read(&store, &retained_os)? + .relations + .iter() + .any(|r| r.relation_id == relation_id) + ); + assert!( + !read(&store, &retained)? + .relations + .iter() + .any(|r| r.relation_id == relation_id) + ); + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + }), + )?; + assert!( + read(&store, &duplicate)? + .relations + .iter() + .any(|r| r.relation_id == relation_id + && r.from.as_ref() == Some(&duplicate) + && r.to.as_ref() == Some(&os)) + ); + assert!( + read(&store, &os)? + .relations + .iter() + .any(|r| r.relation_id == relation_id + && r.from.as_ref() == Some(&duplicate) + && r.to.as_ref() == Some(&os)) + ); + Ok(()) +} + +// Membership is evaluated on an intact graph, including a child being moved. +#[test] +fn scoped_correction_keeps_original_child_membership() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate")?; + let os = child(&store, &duplicate, K::SoftwareInstallation, "OS")?; + let mut working = api::WorkingContext { + roots: vec![retained.clone(), duplicate.clone()], + expected: Some(store.usage()?.revision), + ..Default::default() + }; + let Some(Reply::IdentityCorrection(preview)) = store + .workspace( + &operator(), + api::Request { + working: Some(working.clone()), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + decision: api::IdentityDecision::DistinctInstallations as i32, + mappings: vec![mapping(&duplicate, &retained)], + ..Default::default() + })), + }, + 100, + )? + .result + else { + return Err("preview".into()); + }; + working.expected = Some(preview.revision); + store.workspace( + &operator(), + api::Request { + working: Some(working), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(preview.id), + ..Default::default() + })), + }, + 100, + )?; + assert!( + read(&store, &os)? + .relations + .iter() + .any(|r| r.from.as_ref() == Some(&retained)) + ); + Ok(()) +} + +// One chassis can keep both OS installations, their services and their own +// interface addresses. Explicit reversal restores only the correction delta. +#[test] +fn dual_boot_correction_is_reviewed_atomic_reopenable_and_reversible() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained chassis")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate chassis")?; + let linux = child(&store, &retained, K::SoftwareInstallation, "Linux")?; + let windows = child(&store, &duplicate, K::SoftwareInstallation, "Windows")?; + let service = child(&store, &windows, K::Service, "SMTP")?; + let before = store.usage()?; + let plan = prepare( + &store, + vec![mapping(&duplicate, &retained)], + api::IdentityDecision::DistinctInstallations, + )?; + assert_eq!(plan.relations.len(), 1); + assert!( + read(&store, &duplicate).is_ok(), + "preview does not retire a subject" + ); + assert_eq!( + store.usage()?.rows[Class::Inventory as usize], + before.rows[Class::Inventory as usize] + ); + let result = apply(&store, &plan.id)?; + let before_retry = store.usage()?; + assert_eq!( + apply(&store, &plan.id)?, + result, + "lost-response retry returns the same accepted receipt" + ); + assert_eq!(store.usage()?, before_retry); + assert_eq!(result.retained, vec![retained.clone()]); + assert!(matches!(read(&store, &duplicate), Err(Error::Missing))); + assert!(read(&store, &linux).is_ok()); + assert!(read(&store, &windows).is_ok()); + assert!(read(&store, &service).is_ok()); + let relation = read(&store, &windows)? + .relations + .into_iter() + .find(|r| r.kind == wire::RelationKind::Contains as i32 && r.to.as_ref() == Some(&windows)) + .ok_or("OS placement")?; + assert_eq!(relation.from.as_ref(), Some(&retained)); + drop(store); + let store = Store::open(dir.path(), policy())?; + let Some(Reply::IdentityCorrected(inverse)) = store + .workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![retained.clone(), duplicate.clone()], + ..Default::default() + }), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(result.id.clone()), + ..Default::default() + })), + }, + 100, + )? + .result + else { + return Err("inverse".into()); + }; + assert_eq!(inverse.undo_until, 0); + let before_retry = store.usage()?; + let repeated = call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(result.id), + ..Default::default() + }), + )?; + assert_eq!(repeated, Reply::IdentityCorrected(inverse.clone())); + assert_eq!(store.usage()?, before_retry); + assert!(read(&store, &duplicate)?.item.as_ref().unwrap().revision > 1); + let relation = read(&store, &windows)? + .relations + .into_iter() + .find(|r| r.kind == wire::RelationKind::Contains as i32 && r.to.as_ref() == Some(&windows)) + .ok_or("restored placement")?; + assert_eq!(relation.from.as_ref(), Some(&duplicate)); + assert!(read(&store, &service).is_ok()); + Ok(()) +} + +// Exact lookup exposes only permitted candidates. A reviewed three-layer +// merge preserves independent sources and makes old reports repeat-safe. +#[test] +fn duplicate_installation_reconciles_sources_without_resurrection_or_grant_union() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut first = super::host_import::draft(); + first.report.as_mut().unwrap().findings[0] + .identity + .push(api::HostIdentityClaim { + kind: api::HostIdentityKind::HardwareUuid as i32, + namespace: "smbios/system-uuid".into(), + digest: "a".repeat(64), + quality: api::HostIdentityQuality::Reported as i32, + }); + let mut second = first.clone(); + second.report.as_mut().unwrap().source_basis = "d".repeat(64); + second.report.as_mut().unwrap().findings[2].addresses[0].address = "192.0.2.19".into(); + let a = + super::host_import::accept(&store, &super::host_import::preview(&store, first)?)?.targets; + let b = super::host_import::accept( + &store, + &super::host_import::preview(&store, second.clone())?, + )? + .targets; + let before = store.usage()?; + let Reply::IdentityCandidates(candidates) = call( + &store, + &operator(), + Command::FindIdentityCandidates(api::FindIdentityCandidates { + target: Some(a[0].clone()), + ..Default::default() + }), + )? + else { + return Err("candidates".into()); + }; + assert_eq!(candidates.items.len(), 1); + assert_eq!(candidates.items[0].target.as_ref(), Some(&b[0])); + assert_eq!(store.usage()?, before); + let mut scoped = operator(); + scoped.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&a[0])?]); + let Reply::IdentityCandidates(hidden) = call( + &store, + &scoped, + Command::FindIdentityCandidates(api::FindIdentityCandidates { + target: Some(a[0].clone()), + ..Default::default() + }), + )? + else { + return Err("hidden candidates".into()); + }; + assert!(hidden.items.is_empty()); + let mappings = vec![ + mapping(&b[0], &a[0]), + mapping(&b[1], &a[1]), + mapping(&b[2], &a[2]), + ]; + let plan = prepare( + &store, + mappings.clone(), + api::IdentityDecision::DuplicateInstallation, + )?; + let before = store.usage()?; + assert_eq!( + prepare( + &store, + mappings, + api::IdentityDecision::DuplicateInstallation + )? + .id, + plan.id + ); + assert_eq!( + store.usage()?, + before, + "an unchanged preview writes nothing" + ); + apply(&store, &plan.id)?; + let addresses = read(&store, &a[2])?.current_addresses; + assert_eq!(addresses.len(), 2); + assert!( + addresses + .iter() + .all(|v| v.coverage.as_ref().unwrap().source == "Native fixture") + ); + assert_ne!( + addresses[0].source_id, addresses[1].source_id, + "display labels do not identify collectors" + ); + assert!( + addresses + .iter() + .any(|v| v.observations[0].value.as_ref().unwrap().address == "192.0.2.19") + ); + // Undo must not restore source evidence after that field right is revoked. + let mut restricted = operator(); + restricted.grants[0].write = Fields::Selected(vec![ + "name".into(), + "identity_correction".into(), + "relations".into(), + "remove".into(), + "identity_evidence".into(), + "claimed_owner".into(), + "operator".into(), + ]); + let unchanged = store.usage()?; + assert!(matches!( + call( + &store, + &restricted, + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(plan.id.clone()), + ..Default::default() + }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, unchanged); + let before = store.usage()?; + assert!(matches!( + call(&store, &operator(), Command::HostImport(second))?, + Reply::Mutation(_) + )); + assert_eq!( + store.usage()?, + before, + "old reviewed source mappings resolve to retained identities without re-adoption" + ); + let Reply::ResolvedIdentity(alias) = call( + &store, + &operator(), + Command::ResolveIdentity(api::ResolveIdentity { + original: Some(b[0].clone()), + }), + )? + else { + return Err("alias".into()); + }; + assert_eq!(alias.original.as_ref(), Some(&b[0])); + assert_eq!(alias.canonical.unwrap().target.as_ref(), Some(&a[0])); + let mut retired_only = operator(); + retired_only.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&b[0])?]); + assert!(matches!( + call( + &store, + &retired_only, + Command::ResolveIdentity(api::ResolveIdentity { + original: Some(b[0].clone()) + }) + ), + Err(Error::Denied) + )); + let mut canonical_only = operator(); + canonical_only.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&a[0])?]); + assert!(matches!( + call( + &store, + &canonical_only, + Command::ResolveIdentity(api::ResolveIdentity { + original: Some(b[0].clone()) + }) + ), + Err(Error::Denied) + )); + assert_eq!( + store.usage()?, + before, + "reading aliases does not write or grant access" + ); + Ok(()) +} + +// Stale preview/undo and revoked field rights must fail before any domain +// change. No inverse may overwrite edits accepted after the correction. +#[test] +fn identity_correction_rechecks_revision_fields_and_inverse_dependencies() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let plan = prepare( + &store, + vec![mapping(&a, &b)], + api::IdentityDecision::DistinctInstallations, + )?; + let unchanged = store.usage()?; + assert!(matches!( + store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![a.clone(), b.clone()], + ..Default::default() + }), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(plan.id.clone()), + ..Default::default() + })) + }, + 100 + ), + Err(Error::Conflict) + )); + assert_eq!( + store.usage()?, + unchanged, + "a preview is tied to the reviewed working context" + ); + let mut denied = operator(); + denied.grants[0].write = Fields::Selected(vec![ + "relations".into(), + "remove".into(), + "identity_correction".into(), + ]); + let before = store.usage()?; + assert!(matches!( + call( + &store, + &denied, + Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(plan.id.clone()), + ..Default::default() + }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, before); + let result = apply(&store, &plan.id)?; + create(&store, &operator(), K::Device, "Concurrent edit")?; + let before = store.usage()?; + assert!(matches!( + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(result.id), + ..Default::default() + }) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert!(matches!(read(&store, &a), Err(Error::Missing))); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/correction/continuity.rs b/crates/omloom-store/tests/workspace/correction/continuity.rs new file mode 100644 index 0000000..d973014 --- /dev/null +++ b/crates/omloom-store/tests/workspace/correction/continuity.rs @@ -0,0 +1,328 @@ +use super::*; + +// Another actor's personal CSV approval cannot become approval for a new target. +#[test] +fn correction_preserves_personal_csv_target_and_undo_binding() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut importer = operator(); + importer.actor = "personal-importer".into(); + let input = api::Import { + source: "Personal source".into(), + kind: K::Device as i32, + csv: "external_id,name\nsource-id,Duplicate\n".into(), + mappings: vec![api::Mapping { + source: "name".into(), + field: "name".into(), + }], + ..Default::default() + }; + let Reply::Preview(preview) = call(&store, &importer, Command::Import(input.clone()))? else { + return Err("preview".into()); + }; + let Reply::Mutation(created) = call( + &store, + &importer, + Command::Import(api::Import { + preview: preview.id, + ..Default::default() + }), + )? + else { + return Err("import".into()); + }; + let duplicate = &created.targets[0]; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let preview = prepare( + &store, + vec![mapping(duplicate, &retained)], + api::IdentityDecision::DistinctInstallations, + )?; + let applied = apply(&store, &preview.id)?; + let before = store.usage()?; + let Reply::Preview(retry) = call(&store, &importer, Command::Import(input.clone()))? else { + return Err("retry".into()); + }; + assert!( + retry.id.is_none(), + "personal approval must not silently retarget: {retry:?}" + ); + assert!( + retry + .rows + .iter() + .all(|row| row.change.is_none() && !row.diagnostic.is_empty()) + ); + assert_eq!( + store.usage()?, + before, + "a retired binding cannot create a new inventory identity" + ); + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + }), + )?; + let Reply::Preview(restored) = call(&store, &importer, Command::Import(input))? else { + return Err("restored".into()); + }; + assert_eq!( + restored.rows[0] + .change + .as_ref() + .and_then(|change| change.target.as_ref()), + Some(duplicate) + ); + Ok(()) +} + +// Unrelated matching claims cannot consume another working scope's allowance. +#[test] +fn candidate_limit_counts_only_current_scope() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let claim = api::HostIdentityClaim { + kind: api::HostIdentityKind::HardwareUuid as i32, + namespace: "apple/platform-uuid".into(), + digest: "c".repeat(64), + quality: api::HostIdentityQuality::Reported as i32, + }; + let mut targets = Vec::new(); + for batch in 0..2 { + let mut input = super::super::host_import::draft(); + let report = input.report.as_mut().ok_or("report")?; + report.source_basis = format!("{:064x}", batch + 10); + report.findings = (0..65) + .map(|index| api::HostFinding { + key: format!("hardware/{index}"), + kind: K::Device as i32, + fields: vec![text_field("name", &format!("Candidate {batch}/{index}"))], + identity: vec![claim.clone()], + ..Default::default() + }) + .collect(); + input.selection = report + .findings + .iter() + .map(|finding| api::HostSelection { + key: finding.key.clone(), + ..Default::default() + }) + .collect(); + targets.extend( + super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, input)?, + )? + .targets, + ); + } + assert_eq!(targets.len(), 130); + let before = store.usage()?; + let started = std::time::Instant::now(); + let result = store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![targets[0].clone()], + ..Default::default() + }), + command: Some(Command::FindIdentityCandidates( + api::FindIdentityCandidates { + kind: K::Device as i32, + claims: vec![claim], + ..Default::default() + }, + )), + }, + 100, + ); + eprintln!( + "130 matching claims, one scoped candidate: {:?}; inventory usage: {:?}", + started.elapsed(), + before + ); + let Some(Reply::IdentityCandidates(value)) = result?.result else { + return Err("candidates".into()); + }; + assert_eq!(value.items.len(), 1); + assert_eq!(value.items[0].target.as_ref(), Some(&targets[0])); + assert_eq!(store.usage()?, before, "candidate reads must not write"); + // No readable claims require neither candidate expansion nor persisted state. + let mut limited = operator(); + limited.grants[0].read = Fields::Selected(vec!["name".into(), "relations".into()]); + let Some(Reply::IdentityCandidates(empty)) = store + .workspace( + &limited, + api::Request { + working: Some(api::WorkingContext { + roots: vec![targets[0].clone()], + ..Default::default() + }), + command: Some(Command::FindIdentityCandidates( + api::FindIdentityCandidates { + target: Some(targets[0].clone()), + ..Default::default() + }, + )), + }, + 100, + )? + .result + else { + return Err("empty candidates".into()); + }; + assert!(empty.items.is_empty()); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Reboot/address refresh follows only an existing source binding. Reinstall, +// cloned UUIDs/machine IDs and a movable NIC remain separate until reviewed. +#[test] +fn identity_evidence_never_collapses_reinstall_clone_or_movable_interface() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut input = super::super::host_import::draft(); + let report = input.report.as_mut().ok_or("report")?; + for (index, kind, namespace, quality) in [ + ( + 0, + api::HostIdentityKind::HardwareUuid, + "smbios/system-uuid", + api::HostIdentityQuality::Reported, + ), + ( + 1, + api::HostIdentityKind::InstallationId, + "linux/machine-id", + api::HostIdentityQuality::Reported, + ), + ( + 2, + api::HostIdentityKind::FactoryMac, + "ethernet/permanent", + api::HostIdentityQuality::Movable, + ), + ] { + report.findings[index] + .identity + .push(api::HostIdentityClaim { + kind: kind as i32, + namespace: namespace.into(), + digest: "c".repeat(64), + quality: quality as i32, + }); + } + let original = super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, input.clone())?, + )? + .targets; + let mut reboot = input.clone(); + let report = reboot.report.as_mut().ok_or("report")?; + report.observed_at = 101; + report.boot_basis = "e".repeat(64); + report.findings[2].addresses[0].address = "192.0.2.99".into(); + let refresh = super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, reboot)?, + )?; + assert_eq!(refresh.targets, original); + assert_eq!( + read(&store, &original[2])?.current_addresses[0].observations[0] + .value + .as_ref() + .ok_or("value")? + .address, + "192.0.2.99" + ); + // A new source can discover candidates before adopting anything. + let claims = input.report.as_ref().ok_or("report")?.findings[0] + .identity + .clone(); + let before = store.usage()?; + let plan=store.diagnostic_database().explain_cypher("UNWIND $claims AS claim MATCH (n:OmRow {claim:claim}) MATCH (a:OmRow {key:'asset/'+n.asset}) WHERE a.kind=$kind RETURN DISTINCT properties(a) AS p LIMIT 129")?; + assert!(plan.contains("omloom_claim"), "{plan}"); + let started = std::time::Instant::now(); + let Reply::IdentityCandidates(candidates) = call( + &store, + &operator(), + Command::FindIdentityCandidates(api::FindIdentityCandidates { + claims: claims.clone(), + kind: K::Device as i32, + ..Default::default() + }), + )? + else { + return Err("candidates".into()); + }; + assert_eq!(candidates.items.len(), 1); + eprintln!( + "Scoped native candidate lookup: {:?}; plan: {plan}", + started.elapsed() + ); + assert_eq!(candidates.items[0].target.as_ref(), Some(&original[0])); + assert_eq!(store.usage()?, before); + let mut wrong = api::FindIdentityCandidates { + claims, + kind: K::Interface as i32, + ..Default::default() + }; + assert!(matches!( + call( + &store, + &operator(), + Command::FindIdentityCandidates(wrong.clone()) + ), + Err(Error::Invalid) + )); + wrong.kind = K::Device as i32; + wrong.target = Some(original[0].clone()); + assert!(matches!( + call(&store, &operator(), Command::FindIdentityCandidates(wrong)), + Err(Error::Invalid) + )); + let mut reinstall = input.clone(); + let report = reinstall.report.as_mut().ok_or("report")?; + report.source_basis = "d".repeat(64); + report.findings[1].identity[0].digest = "d".repeat(64); + let second = super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, reinstall)?, + )? + .targets; + let mut cloned = input; + cloned.report.as_mut().ok_or("report")?.source_basis = "f".repeat(64); + let third = super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, cloned)?, + )? + .targets; + assert_ne!(second[0], original[0]); + assert_ne!(third[0], original[0]); + assert_ne!(third[0], second[0]); + for subject in [&original[0], &second[0], &third[0]] { + assert!(read(&store, subject).is_ok()); + } + let Reply::IdentityCandidates(candidates) = call( + &store, + &operator(), + Command::FindIdentityCandidates(api::FindIdentityCandidates { + target: Some(original[2].clone()), + ..Default::default() + }), + )? + else { + return Err("NIC candidates".into()); + }; + assert_eq!( + candidates.items.len(), + 2, + "movable evidence supports review without merging either NIC" + ); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/correction/safety.rs b/crates/omloom-store/tests/workspace/correction/safety.rs new file mode 100644 index 0000000..2bdc88c --- /dev/null +++ b/crates/omloom-store/tests/workspace/correction/safety.rs @@ -0,0 +1,537 @@ +use super::*; + +// Correcting a migrated host cannot reactivate inheritance tied to the old placement. +#[test] +fn correction_and_undo_keep_stale_placement_inheritance_unavailable() -> TestResult { + use super::super::vm_placement::{hypervisor, responsibility}; + use api::{AssignmentMode as M, ResponsibilityFacet as F, ResponsibilityStatus as S}; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "Original host")?; + let duplicate = hypervisor(&store, "Migrated host")?; + let retained = hypervisor(&store, "Retained host")?; + responsibility( + &store, + &first, + vec![api::ResponsibilityAssignment { + facet: F::ClaimedOwner as i32, + mode: M::Explicit as i32, + party: Some(api::PartyRef { + issuer: "company.example".into(), + subject: "original".into(), + name: "Original team".into(), + kind: api::PartyKind::Team as i32, + }), + ..Default::default() + }], + )?; + responsibility( + &store, + &retained, + vec![api::ResponsibilityAssignment { + facet: F::ClaimedOwner as i32, + mode: M::Explicit as i32, + party: Some(api::PartyRef { + issuer: "company.example".into(), + subject: "operations".into(), + name: "New team".into(), + kind: api::PartyKind::Team as i32, + }), + ..Default::default() + }], + )?; + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(guest.clone()), + to: Some(first), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }), + remove: false, + }), + )?; + let mut placement = read(&store, &guest)? + .relations + .into_iter() + .find(|r| r.kind == wire::RelationKind::RunsOn as i32) + .ok_or("placement")?; + responsibility( + &store, + &guest, + vec![api::ResponsibilityAssignment { + facet: F::ClaimedOwner as i32, + mode: M::Inherit as i32, + source_relation: Some(placement.relation_id.clone()), + source_revision: Some(placement.revision), + ..Default::default() + }], + )?; + let inherited = responsibility(&store, &guest, Vec::new())?; + assert_eq!( + inherited + .effective + .iter() + .find(|v| v.facet == F::ClaimedOwner as i32) + .and_then(|v| v.party.as_ref()) + .ok_or("inherited owner before migration")? + .subject, + "original" + ); + placement.to = Some(duplicate.clone()); + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(placement), + remove: false, + }), + )?; + let unavailable = |store: &Store| -> TestResult { + let state = responsibility(store, &guest, Vec::new())?; + let owner = state + .effective + .iter() + .find(|v| v.facet == F::ClaimedOwner as i32) + .ok_or("owner")?; + assert_eq!(owner.status, S::SourceUnavailable as i32); + assert!(owner.party.is_none()); + Ok(()) + }; + unavailable(&store)?; + let mut choice = mapping(&duplicate, &retained); + choice.fields.push(api::IdentityFieldChoice { + name: "virtualization_role".into(), + use_duplicate: false, + }); + let preview = prepare( + &store, + vec![choice], + api::IdentityDecision::DuplicateInstallation, + )?; + let applied = apply(&store, &preview.id)?; + unavailable(&store)?; + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + }), + )?; + unavailable(&store)?; + Ok(()) +} + +// Blind write permission cannot disclose either a mapped root's or its child's relations. +#[test] +fn correction_requires_relation_read_on_all_endpoints() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate")?; + let os = child(&store, &duplicate, K::SoftwareInstallation, "OS")?; + for permitted in [ + Vec::new(), + vec![retained.clone(), duplicate.clone()], + vec![duplicate.clone(), os.clone()], + ] { + let mut actor = operator(); + actor.grants[0].read = Fields::Selected(vec!["name".into(), "identity_evidence".into()]); + actor.grants.push(Grant { + scope: Scope::Targets( + permitted + .iter() + .map(AssetRef::try_from) + .collect::>()?, + ), + read: Fields::Selected(vec!["relations".into()]), + write: Fields::Selected(Vec::new()), + writable: false, + }); + let before = store.usage()?; + let result = store.workspace( + &actor, + api::Request { + working: Some(api::WorkingContext { + expected: Some(before.revision), + ..Default::default() + }), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + decision: api::IdentityDecision::DistinctInstallations as i32, + mappings: vec![mapping(&duplicate, &retained)], + ..Default::default() + })), + }, + 100, + ); + assert!( + matches!(result, Err(Error::Denied)), + "unreadable impact must not escape: {result:?}" + ); + assert_eq!(store.usage()?, before); + } + Ok(()) +} + +fn native_pair(store: &Store) -> Result<(Vec, Vec), Error> { + let mut first = super::super::host_import::draft(); + let a = super::super::host_import::accept( + store, + &super::super::host_import::preview(store, first.clone())?, + )? + .targets; + first.report.as_mut().ok_or(Error::Corrupt)?.source_basis = "e".repeat(64); + let b = super::super::host_import::accept( + store, + &super::super::host_import::preview(store, first)?, + )? + .targets; + Ok((a, b)) +} + +// Unlike qualified native bindings, two explicit manual namespaces with the +// same label need a reviewed rename. No rejected preview may overwrite either. +#[test] +fn manual_sources_collide_explicitly_and_keep_individual_windows() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let (a, b) = native_pair(&store)?; + for (target, address, time) in [(&a[2], "192.0.2.30", 50), (&b[2], "192.0.2.31", 60)] { + call( + &store, + &operator(), + Command::InterfaceAddresses(api::InterfaceAddresses { + interface: Some(target.clone()), + source: "Manual survey".into(), + observed_at: time, + addresses: vec![api::CurrentAddress { + address: address.into(), + stability: api::AddressStability::Stable as i32, + state: api::AddressState::Preferred as i32, + ..Default::default() + }], + complete: true, + }), + )?; + } + let mut mappings = vec![ + mapping(&b[0], &a[0]), + mapping(&b[1], &a[1]), + mapping(&b[2], &a[2]), + ]; + let before = store.usage()?; + assert!(matches!( + prepare( + &store, + mappings.clone(), + api::IdentityDecision::DuplicateInstallation + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + mappings[2].sources.push(api::IdentitySourceChoice { + before: "Manual survey".into(), + after: "Separate survey".into(), + }); + let prepared = prepare( + &store, + mappings, + api::IdentityDecision::DuplicateInstallation, + )?; + apply(&store, &prepared.id)?; + let addresses = read(&store, &a[2])?.current_addresses; + assert_eq!(addresses.len(), 4); + for (name, address, time) in [ + ("Manual survey", "192.0.2.30", 50), + ("Separate survey", "192.0.2.31", 60), + ] { + let view = addresses + .iter() + .find(|v| v.coverage.as_ref().is_some_and(|c| c.source == name)) + .ok_or("source")?; + assert_eq!(view.observations[0].observed_at, Some(time)); + assert_eq!( + view.observations[0].value.as_ref().ok_or("value")?.address, + address + ); + } + Ok(()) +} + +// Hardware equality cannot silently carry physical port/cable attachments. +// Individual confirmation still passes through ordinary relation validation. +#[test] +fn hardware_correction_requires_each_physical_attachment_confirmation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::Device, "Retained")?; + let b = create(&store, &operator(), K::Device, "Duplicate")?; + let port = child(&store, &b, K::Port, "Physical socket")?; + let relations = read(&store, &b)?.relations; + let physical = relations + .iter() + .find(|r| r.kind == wire::RelationKind::HasPort as i32) + .ok_or("physical attachment")?; + let mut selected = mapping(&b, &a); + let before = store.usage()?; + assert!(matches!( + prepare( + &store, + vec![selected.clone()], + api::IdentityDecision::DistinctInstallations + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + selected.attachments.push(physical.relation_id.clone()); + let preview = prepare( + &store, + vec![selected], + api::IdentityDecision::DistinctInstallations, + )?; + apply(&store, &preview.id)?; + assert!( + read(&store, &port)? + .relations + .iter() + .any(|r| r.relation_id == physical.relation_id && r.from.as_ref() == Some(&a)) + ); + Ok(()) +} + +// Finite review and inverse custody expire independently of minimal retirement +// fences. Bounded cleanup removes detail without resurrecting a retired subject. +#[test] +fn correction_custody_expires_and_cleanup_preserves_alias_fences() -> TestResult { + let dir = tempfile::tempdir()?; + let limits = policy(); + let store = Store::open(dir.path(), limits.clone())?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + let before = store.usage()?; + let request = api::Request { + working: Some(api::WorkingContext::default()), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(preview.id.clone()), + ..Default::default() + })), + }; + assert!(matches!( + store.workspace(&operator(), request, preview.expires_at), + Err(Error::Expired) + )); + assert_eq!(store.usage()?, before); + let applied = apply(&store, &preview.id)?; + assert!(matches!( + store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext::default()), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + })), + }, + applied.undo_until + ), + Err(Error::Expired) + )); + let mut cursor = None; + loop { + let result = store.cleanup(10_000, cursor.as_deref())?; + cursor = result.cursor; + if cursor.is_none() { + break; + } + } + assert_eq!(store.usage()?.rows[Class::Detail as usize], 0); + assert!(matches!(read(&store, &b), Err(Error::Missing))); + let Reply::ResolvedIdentity(alias) = call( + &store, + &operator(), + Command::ResolveIdentity(api::ResolveIdentity { original: Some(b) }), + )? + else { + return Err("alias".into()); + }; + assert_eq!(alias.canonical.ok_or("canonical")?.target, Some(a)); + Ok(()) +} + +// Captured inverse storage is admitted before accepting an irreversible domain +// correction. A full detail quota leaves both subjects and the preview intact. +#[test] +fn inverse_quota_failure_rolls_back_the_complete_correction() -> TestResult { + let dir = tempfile::tempdir()?; + let mut limits = policy(); + // Two creation audits plus two correction audits fit; inverse custody does not. + limits.classes[Class::Detail as usize].rows = 4; + let store = Store::open(dir.path(), limits)?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + let before = store.usage()?; + assert!(matches!(apply(&store, &preview.id), Err(Error::Limit))); + assert_eq!(store.usage()?, before); + assert!(read(&store, &a).is_ok()); + assert!(read(&store, &b).is_ok()); + Ok(()) +} + +// A backup after an accepted correction preserves canonical resolution and the +// retired source fence; restore must not turn old source identities into assets. +#[test] +fn restored_correction_keeps_canonical_identity_and_retirement() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + apply(&store, &preview.id)?; + let mut archive = Vec::new(); + store.export(&mut archive)?; + let dest = tempfile::tempdir()?; + let restored = Store::restore(dest.path(), &archive, policy(), archive.len() as u64)?; + assert!(matches!(read(&restored, &b), Err(Error::Missing))); + let Reply::ResolvedIdentity(alias) = call( + &restored, + &operator(), + Command::ResolveIdentity(api::ResolveIdentity { original: Some(b) }), + )? + else { + return Err("alias".into()); + }; + assert_eq!(alias.canonical.ok_or("canonical")?.target, Some(a)); + Ok(()) +} + +// An unresolved effect blocks correction. A proven terminal intent stays on +// its original incarnation and must not prevent ordinary inventory correction. +#[test] +fn terminal_operations_are_preserved_without_retargeting() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::VirtualMachine, "Retained VM")?; + let b = create(&store, &operator(), K::VirtualMachine, "Duplicate VM")?; + let target = AssetRef::try_from(&b)?; + let id = store.accept(&plan(target, &[]), 100)?; + assert!(matches!( + prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations + ), + Err(Error::UnsafeTransition) + )); + let lease = store.claim(id, "worker", 101, 200)?; + store.reconcile( + &lease, + 1, + wire::OperationState::Cancelled, + wire::EffectOutcome::NotSent, + None, + 102, + )?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + apply(&store, &preview.id)?; + assert_eq!(store.operation(id)?.target, target); + assert!(store.pending(None)?.is_empty()); + Ok(()) +} + +// Interface equivalence cannot choose between two still-independent OS parents, +// and a cyclic mapping cannot create an alias loop or partial relation changes. +#[test] +fn incomplete_layer_mapping_and_alias_cycles_are_atomic_conflicts() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let (a, b) = native_pair(&store)?; + let before = store.usage()?; + assert!(matches!( + prepare( + &store, + vec![mapping(&b[2], &a[2])], + api::IdentityDecision::DuplicateInstallation + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert!(matches!( + prepare( + &store, + vec![mapping(&b[0], &a[0]), mapping(&a[0], &b[0])], + api::IdentityDecision::DuplicateInstallation + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Concurrent acceptance of one reviewed change yields one canonical effect. +// A losing CAS can retry the exact receipt without another retirement/audit. +#[test] +fn concurrent_correction_acceptance_is_repeat_safe() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + let gate = std::sync::Barrier::new(2); + let outcomes = std::thread::scope(|scope| { + let first = scope.spawn(|| { + gate.wait(); + apply(&store, &preview.id) + }); + let second = scope.spawn(|| { + gate.wait(); + apply(&store, &preview.id) + }); + [ + first.join().expect("first correction writer"), + second.join().expect("second correction writer"), + ] + }); + let accepted = outcomes + .iter() + .find_map(|v| v.as_ref().ok()) + .ok_or("no correction accepted")?; + for outcome in &outcomes { + match outcome { + Ok(value) => assert_eq!(value, accepted), + Err(Error::Conflict) => {} + Err(error) => return Err(error.to_string().into()), + } + } + let before = store.usage()?; + assert_eq!(apply(&store, &preview.id)?, *accepted); + assert_eq!(store.usage()?, before); + assert!(matches!(read(&store, &b), Err(Error::Missing))); + assert!(read(&store, &a).is_ok()); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/host_import.rs b/crates/omloom-store/tests/workspace/host_import.rs index 74e6f74..347136a 100644 --- a/crates/omloom-store/tests/workspace/host_import.rs +++ b/crates/omloom-store/tests/workspace/host_import.rs @@ -1,7 +1,7 @@ use super::*; use api::{reply::Result as Reply, request::Command}; -fn draft() -> api::HostImport { +pub(super) fn draft() -> api::HostImport { api::HostImport { source: "Native fixture".into(), report: Some(Box::new(api::HostReport { @@ -53,13 +53,362 @@ fn draft() -> api::HostImport { network_parts: Vec::new(), } } -fn preview(store: &Store, request: api::HostImport) -> Result { + +fn identity_claim( + kind: api::HostIdentityKind, + namespace: &str, + digest: char, +) -> api::HostIdentityClaim { + api::HostIdentityClaim { + kind: kind as i32, + namespace: namespace.into(), + digest: digest.to_string().repeat(64), + quality: api::HostIdentityQuality::Reported as i32, + } +} + +// Identity equality is layer-specific evidence, not a host/OS merge. Missing +// later claims retain their exact old collection window rather than freshening. +#[test] +fn host_identity_evidence_is_current_scoped_and_not_freshened_by_omission() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut request = draft(); + let report = request.report.as_mut().unwrap(); + report.findings[0].identity.push(identity_claim( + api::HostIdentityKind::HardwareUuid, + "apple/platform-uuid", + 'a', + )); + report.findings[1].identity.push(identity_claim( + api::HostIdentityKind::InstallationId, + "macos/apfs-volume", + 'a', + )); + let accepted = accept(&store, &preview(&store, request.clone())?)?; + let host = accepted.targets[0].clone(); + let installation = accepted.targets[1].clone(); + let read = |target: wire::AssetRef| -> Result { + let Reply::Detail(value) = call( + &store, + &operator(), + Command::Read(api::Read { + target: Some(target), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) + }; + let first = read(host.clone())?; + let os = read(installation)?; + assert_eq!(first.identity.len(), 1); + assert_eq!(os.identity.len(), 1); + assert_ne!( + first.identity[0].claim.as_ref().unwrap().kind, + os.identity[0].claim.as_ref().unwrap().kind + ); + assert_eq!(first.identity[0].observed_at, 100); + let before = store.usage()?; + assert!(matches!( + call(&store, &operator(), Command::HostImport(request.clone()))?, + Reply::Mutation(_) + )); + assert_eq!( + before, + store.usage()?, + "identical claim input writes nothing" + ); + request.report.as_mut().unwrap().observed_at = 200; + request.report.as_mut().unwrap().findings[0] + .identity + .clear(); + accept(&store, &preview(&store, request)?)?; + let current = read(host)?; + assert_eq!( + first.identity, current.identity, + "omission does not reattest identity" + ); + let before = store.usage()?; + read(accepted.targets[0].clone())?; + assert_eq!( + before, + store.usage()?, + "reading claims creates no history or touches" + ); + Ok(()) +} + +// A source cannot change a claim at the same instant or place installation +// evidence on a hardware identity, even through a forged native report. +#[test] +fn host_identity_rejects_layer_forgery_and_same_instant_equivocation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut request = draft(); + request.report.as_mut().unwrap().findings[0] + .identity + .push(identity_claim( + api::HostIdentityKind::InstallationId, + "linux/machine-id", + 'a', + )); + let before = store.usage()?; + assert!(matches!( + preview(&store, request.clone()), + Err(Error::Invalid) + )); + assert_eq!(before, store.usage()?); + request.report.as_mut().unwrap().findings[0].identity[0].kind = + api::HostIdentityKind::HardwareUuid as i32; + accept(&store, &preview(&store, request.clone())?)?; + request.report.as_mut().unwrap().findings[0].identity[0].digest = "b".repeat(64); + let before = store.usage()?; + assert!(matches!(preview(&store, request), Err(Error::Conflict))); + assert_eq!(before, store.usage()?); + Ok(()) +} + +// Pair comparison reads only the two selected current subjects, applies each +// field's actual rights and cannot freshen evidence or change inventory. +#[test] +fn host_identity_comparison_is_read_only_and_checks_both_subjects() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut request = draft(); + request.report.as_mut().unwrap().findings[0] + .identity + .push(identity_claim( + api::HostIdentityKind::HardwareUuid, + "apple/platform-uuid", + 'a', + )); + let accepted = accept(&store, &preview(&store, request)?)?; + let pair = api::CompareHostIdentity { + left: Some(accepted.targets[0].clone()), + right: Some(accepted.targets[1].clone()), + include_relations: false, + }; + let before = store.usage()?; + let Reply::HostIdentityComparison(value) = call( + &store, + &operator(), + Command::CompareHostIdentity(pair.clone()), + )? + else { + return Err("comparison".into()); + }; + assert_eq!(value.left.as_ref().unwrap().identity.len(), 1); + assert!(value.right.as_ref().unwrap().identity.is_empty()); + assert_eq!(value.revision, before.revision); + assert_eq!(before, store.usage()?); + let mut restricted = operator(); + restricted.grants[0].read = Fields::Selected(vec!["name".into()]); + let Reply::HostIdentityComparison(value) = call( + &store, + &restricted, + Command::CompareHostIdentity(pair.clone()), + )? + else { + return Err("restricted comparison".into()); + }; + assert!( + value.left.unwrap().identity.is_empty(), + "hidden claims are not disclosed on a readable host" + ); + restricted.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&accepted.targets[0])?]); + assert!(matches!( + call( + &store, + &restricted, + Command::CompareHostIdentity(pair.clone()) + ), + Err(Error::Denied) + )); + assert_eq!(before, store.usage()?); + let unrelated = create(&store, &operator(), K::Device, "Outside selected scope")?; + let before = store.usage()?; + let result = store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![unrelated], + ..Default::default() + }), + command: Some(Command::CompareHostIdentity(pair)), + }, + 100, + ); + assert!( + matches!(result, Err(Error::Denied)), + "a broader actor grant cannot silently widen the working context" + ); + assert_eq!(before, store.usage()?); + Ok(()) +} + +// Address replacement follows the existing reviewed mapping, affects only its +// source and cannot move a service's literal bind to the new interface address. +#[test] +fn host_address_preview_is_a_source_local_delta_without_identity_changes() -> TestResult { + fn detail(store: &Store, target: &wire::AssetRef) -> Result { + let Reply::Detail(value) = call( + store, + &operator(), + Command::Read(api::Read { + target: Some(target.clone()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) + } + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut request = draft(); + request + .report + .as_mut() + .unwrap() + .findings + .push(api::HostFinding { + key: "service".into(), + kind: K::Service as i32, + parent: Some("installation".into()), + fields: vec![text_field("name", "Literal listener")], + ..Default::default() + }); + request.selection.push(api::HostSelection { + key: "service".into(), + ..Default::default() + }); + let initial = preview(&store, request.clone()).map_err(|e| format!("initial preview: {e}"))?; + let accepted = accept(&store, &initial).map_err(|e| format!("initial acceptance: {e}"))?; + let interface = &accepted.targets[2]; + let service = &accepted.targets[3]; + let address = |value: &str| api::CurrentAddress { + address: value.into(), + prefix_length: Some(24), + stability: api::AddressStability::Stable as i32, + state: api::AddressState::Preferred as i32, + }; + call( + &store, + &operator(), + Command::InterfaceAddresses(api::InterfaceAddresses { + interface: Some(interface.clone()), + source: "Independent source".into(), + observed_at: 100, + addresses: vec![address("192.0.2.8")], + complete: true, + }), + )?; + store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + focus: Some(accepted.targets[1].clone()), + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::HostNetwork(api::HostNetwork { + installation: Some(accepted.targets[1].clone()), + source: "Listener source".into(), + observed_at: 100, + basis: "b".repeat(64), + sockets: vec![api::SocketBind { + service: Some(service.clone()), + protocol: "tcp".into(), + family: "IPv4".into(), + bind: "192.0.2.7".into(), + port: 443, + ..Default::default() + }], + socket_coverage: "verified".into(), + route_coverage: "not collected".into(), + application_coverage: "not collected".into(), + packet_filter_coverage: "not collected".into(), + ..Default::default() + })), + }, + 100, + )?; + let listener = detail(&store, service) + .map_err(|e| format!("read listener: {e}"))? + .host_network; + assert!(!listener.is_empty()); + let report = request.report.as_mut().unwrap(); + report.observed_at = 200; + report.findings[2].addresses = vec![address("192.0.2.19")]; + let plan = preview(&store, request.clone()).map_err(|e| format!("replacement preview: {e}"))?; + let delta = plan.rows[2].addresses.as_ref().ok_or("address delta")?; + assert_eq!(delta.source, "Native fixture"); + assert_eq!(delta.added, vec![address("192.0.2.19")]); + assert_eq!(delta.removed, vec![address("192.0.2.7")]); + assert!(delta.updated.is_empty()); + assert!(delta.complete); + let mut restricted = operator(); + restricted.grants[0].read = Fields::Selected(vec!["name".into(), "relations".into()]); + let before_restricted_review = store.usage()?; + let Reply::Preview(hidden) = call(&store, &restricted, Command::HostImport(request.clone())) + .map_err(|e| format!("restricted review: {e}"))? + else { + return Err("restricted preview".into()); + }; + assert_eq!( + hidden.id, plan.id, + "repeating the same review does not store another copy" + ); + assert!( + hidden.rows.iter().all(|row| row.addresses.is_none()), + "a cached review cannot disclose previously readable addresses after revocation" + ); + assert_eq!(store.usage()?, before_restricted_review); + assert_eq!(accept(&store, &plan)?.targets, accepted.targets); + assert_eq!(detail(&store, service)?.host_network, listener); + let current = detail(&store, interface)?.current_addresses; + assert_eq!(current.len(), 2); + assert!(current.iter().any( + |s| s.coverage.as_ref().unwrap().source == "Independent source" + && s.observations[0].value.as_ref().unwrap().address == "192.0.2.8" + )); + let report = request.report.as_mut().unwrap(); + report.observed_at = 300; + report.findings[2].addresses = vec![address("192.0.2.20")]; + report.findings[2].addresses_complete = false; + let plan = preview(&store, request)?; + let delta = plan.rows[2].addresses.as_ref().ok_or("partial delta")?; + assert_eq!(delta.added, vec![address("192.0.2.20")]); + assert!(delta.removed.is_empty()); + assert!(!delta.complete); + accept(&store, &plan)?; + let current = detail(&store, interface)?.current_addresses; + let native = current + .iter() + .find(|s| s.coverage.as_ref().unwrap().source == "Native fixture") + .ok_or("native source")?; + assert_eq!( + native + .observations + .iter() + .map(|v| (v.value.as_ref().unwrap().address.as_str(), v.observed_at)) + .collect::>(), + vec![("192.0.2.19", Some(200)), ("192.0.2.20", Some(300))] + ); + assert_eq!(detail(&store, service)?.host_network, listener); + Ok(()) +} +pub(super) fn preview(store: &Store, request: api::HostImport) -> Result { let Reply::Preview(value) = call(store, &operator(), Command::HostImport(request))? else { return Err(Error::Corrupt); }; Ok(value) } -fn accept(store: &Store, preview: &api::Preview) -> Result { +pub(super) fn accept(store: &Store, preview: &api::Preview) -> Result { let Reply::Mutation(value) = call( store, &operator(), diff --git a/crates/omloom-store/tests/workspace/projections.rs b/crates/omloom-store/tests/workspace/projections.rs index e63305c..f4a1799 100644 --- a/crates/omloom-store/tests/workspace/projections.rs +++ b/crates/omloom-store/tests/workspace/projections.rs @@ -758,6 +758,87 @@ fn level_browse_returns_containers_then_direct_children() -> TestResult { Ok(()) } +// Root filtering must keep an authorized child of hidden parents, but exclude +// it when either typed direction has a visible parent, including mixed cases. +#[test] +fn level_browse_preserves_hidden_and_mixed_parent_semantics() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let visible_os = create(&store, &actor, K::SoftwareInstallation, "Visible OS")?; + let hidden_os = create(&store, &actor, K::SoftwareInstallation, "Hidden OS")?; + let visible_vm = create(&store, &actor, K::VirtualMachine, "Visible VM")?; + let hidden_vm = create(&store, &actor, K::VirtualMachine, "Hidden VM")?; + let forward_owned = child(&store, &actor, &visible_os, K::Service, "Forward owned")?; + let reverse_owned = child(&store, &actor, &hidden_os, K::Service, "Reverse owned")?; + let hidden_owned = child(&store, &actor, &hidden_os, K::Service, "Hidden owners only")?; + let orphan = create(&store, &actor, K::Service, "Unplaced service")?; + for (service, host) in [ + (&forward_owned, &hidden_vm), + (&reverse_owned, &visible_vm), + (&hidden_owned, &hidden_vm), + ] { + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(service.clone()), + to: Some(host.clone()), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }), + remove: false, + }), + )?; + } + let targets = [ + &visible_os, + &visible_vm, + &forward_owned, + &reverse_owned, + &hidden_owned, + &orphan, + ]; + let mut restricted = actor.clone(); + restricted.grants[0].scope = Scope::Targets( + targets + .into_iter() + .map(AssetRef::try_from) + .collect::>()?, + ); + let usage = store.usage()?; + let Reply::Page(page) = call( + &store, + &restricted, + Command::Query(api::Query { + level_only: true, + ..query() + }), + )? + else { + return Err("page".into()); + }; + let actual: std::collections::BTreeSet<_> = page + .items + .iter() + .map(|i| { + i.target + .as_ref() + .map(|t| t.asset_id.as_str()) + .ok_or("target") + }) + .collect::>()?; + let expected: std::collections::BTreeSet<_> = + [&visible_os, &visible_vm, &hidden_owned, &orphan] + .map(|t| t.asset_id.as_str()) + .into(); + assert_eq!(actual, expected); + assert_eq!(page.total, 4); + assert_eq!(store.usage()?, usage); + Ok(()) +} + // A small estate must not re-expand the estate for every candidate's parent probe. // The wall-clock ceiling is a required regression gate; indexed plans alone do not bound latency. #[test] diff --git a/crates/omloom-store/tests/workspace/responsibility.rs b/crates/omloom-store/tests/workspace/responsibility.rs index 30e82c2..fc120ca 100644 --- a/crates/omloom-store/tests/workspace/responsibility.rs +++ b/crates/omloom-store/tests/workspace/responsibility.rs @@ -923,7 +923,8 @@ fn responsibility_global_filter_excludes_unselected_hosting_sources() -> TestRes let actor = operator(); let first = create(&store, &actor, K::Device, "Administrative host")?; let second = create(&store, &actor, K::Device, "Placement host")?; - let guest = create(&store, &actor, K::VirtualMachine, "Unrelated guest")?; + // Services can have multiple hosting relations with one selected administrative source. + let guest = create(&store, &actor, K::Service, "Unrelated service")?; responsibility(&store, &actor, &first, Action::Apply, vec![owner("other")])?; responsibility( &store, @@ -987,7 +988,7 @@ fn responsibility_global_filter_excludes_unselected_hosting_sources() -> TestRes ..query() }, api::Query { - view: api::View::Services as i32, + view: api::View::Virtualization as i32, ..query() }, ] { diff --git a/crates/omloom-store/tests/workspace/vm_placement.rs b/crates/omloom-store/tests/workspace/vm_placement.rs new file mode 100644 index 0000000..a5befdb --- /dev/null +++ b/crates/omloom-store/tests/workspace/vm_placement.rs @@ -0,0 +1,629 @@ +use super::*; +use api::{reply::Result as Reply, request::Command}; +use wire::RelationKind as R; + +pub(super) fn hypervisor(store: &Store, name: &str) -> Result { + let Reply::Mutation(result) = call( + store, + &operator(), + Command::Write(api::Write { + kind: K::SoftwareInstallation as i32, + changes: vec![ + text_field("name", name), + text_field("virtualization_role", "VIRTUALIZATION_ROLE_HYPERVISOR"), + ], + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + result.targets.into_iter().next().ok_or(Error::Corrupt) +} + +fn place(store: &Store, guest: &wire::AssetRef, host: &wire::AssetRef) -> Result { + call( + store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(guest.clone()), + to: Some(host.clone()), + kind: R::RunsOn as i32, + ..Default::default() + }), + remove: false, + }), + ) +} + +fn placement(store: &Store, guest: &wire::AssetRef) -> Result { + let Reply::Detail(detail) = call( + store, + &operator(), + Command::Read(api::Read { + target: Some(guest.clone()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + detail + .relations + .into_iter() + .find(|r| r.kind == R::RunsOn as i32) + .ok_or(Error::Missing) +} + +fn prepare( + store: &Store, + actor: &Context, + guest: &wire::AssetRef, + host: Option<&wire::AssetRef>, +) -> Result { + let Reply::VmPlacementReview(value) = call( + store, + actor, + Command::PrepareVmPlacement(api::PrepareVmPlacement { + vm: Some(guest.clone()), + destination: host.cloned(), + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) +} +fn record( + store: &Store, + actor: &Context, + review: &api::VmPlacementReview, +) -> Result { + let mut relation = review.current.clone().ok_or(Error::Missing)?; + relation.to = review.destination.as_ref().and_then(|i| i.target.clone()); + store.workspace( + actor, + api::Request { + working: Some(api::WorkingContext { + expected: Some(review.revision), + ..Default::default() + }), + command: Some(Command::Relation(api::Relationship { + relation: Some(relation), + remove: false, + })), + }, + 100, + ) +} + +// An unplaced VM uses the reviewed create-relation path, with the same root CAS. +#[test] +fn first_placement_is_reviewed_read_only_and_revision_checked() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Unplaced guest")?; + let host = hypervisor(&store, "Destination")?; + let before = store.usage()?; + let review = prepare(&store, &operator(), &guest, Some(&host))?; + assert!(review.current.is_none()); + assert!(review.current_host.is_none()); + assert_eq!( + review + .destination + .as_ref() + .and_then(|item| item.target.as_ref()), + Some(&host) + ); + assert_eq!(store.usage()?, before); + let submit = |view: &api::VmPlacementReview| { + store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![guest.clone(), host.clone()], + expected: Some(view.revision), + ..Default::default() + }), + command: Some(Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(guest.clone()), + to: Some(host.clone()), + kind: R::RunsOn as i32, + ..Default::default() + }), + remove: false, + })), + }, + 100, + ) + }; + create(&store, &operator(), K::Device, "Concurrent change")?; + assert!(matches!(submit(&review), Err(Error::Conflict))); + assert!(read(&store, &guest)?.relations.is_empty()); + let fresh = prepare(&store, &operator(), &guest, Some(&host))?; + submit(&fresh)?; + let current = placement(&store, &guest)?; + assert_eq!(current.from.as_ref(), Some(&guest)); + assert_eq!(current.to.as_ref(), Some(&host)); + assert_eq!(current.revision, 1); + assert_eq!( + prepare(&store, &operator(), &guest, None)?.current, + Some(current) + ); + Ok(()) +} +fn child(store: &Store, parent: &wire::AssetRef, kind: K) -> Result { + let Reply::Mutation(value) = call( + store, + &operator(), + Command::Write(api::Write { + parent: Some(parent.clone()), + kind: kind as i32, + changes: vec![text_field("name", "Guest component")], + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + value.targets.into_iter().next().ok_or(Error::Corrupt) +} +fn address(store: &Store, interface: &wire::AssetRef, value: &str, time: u64) -> Result<(), Error> { + call( + store, + &operator(), + Command::InterfaceAddresses(api::InterfaceAddresses { + interface: Some(interface.clone()), + source: "Guest report".into(), + observed_at: time, + complete: true, + addresses: vec![api::CurrentAddress { + address: value.into(), + stability: api::AddressStability::Stable as i32, + state: api::AddressState::Preferred as i32, + ..Default::default() + }], + }), + )?; + Ok(()) +} +fn read(store: &Store, target: &wire::AssetRef) -> Result { + let Reply::Detail(value) = call( + store, + &operator(), + Command::Read(api::Read { + target: Some(target.clone()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) +} + +pub(super) fn responsibility( + store: &Store, + target: &wire::AssetRef, + changes: Vec, +) -> Result { + let request = api::Responsibility { + target: Some(target.clone()), + expected: (!changes.is_empty()) + .then(|| { + store + .asset(AssetRef::try_from(target).map_err(|_| Error::Invalid)?) + .map(|a| a.revision) + }) + .transpose()?, + action: if changes.is_empty() { + api::ResponsibilityAction::Read + } else { + api::ResponsibilityAction::Apply + } as i32, + changes, + ..Default::default() + }; + let value = store + .workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::Responsibility(request)), + }, + 100, + )? + .result + .ok_or(Error::Corrupt)?; + let Reply::Responsibility(value) = value else { + return Err(Error::Corrupt); + }; + Ok(value) +} + +// Explicit placement-bound inheritance must become unavailable, rather than +// silently taking the new host's owner. Independent direct declarations survive. +#[test] +fn migration_previews_only_permitted_inheritance_and_never_transfers_ownership() -> TestResult { + use api::{AssignmentMode as M, ResponsibilityFacet as F, ResponsibilityStatus as S}; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + let assigned = |facet: F, subject: &str| api::ResponsibilityAssignment { + facet: facet as i32, + mode: M::Explicit as i32, + party: Some(api::PartyRef { + issuer: "company.example".into(), + subject: subject.into(), + name: subject.into(), + kind: api::PartyKind::Team as i32, + }), + ..Default::default() + }; + responsibility( + &store, + &first, + vec![assigned(F::ClaimedOwner, "Original team")], + )?; + responsibility( + &store, + &second, + vec![assigned(F::ClaimedOwner, "Other team")], + )?; + place(&store, &guest, &first)?; + let old = placement(&store, &guest)?; + responsibility( + &store, + &guest, + vec![ + api::ResponsibilityAssignment { + facet: F::ClaimedOwner as i32, + mode: M::Inherit as i32, + source_relation: Some(old.relation_id.clone()), + source_revision: Some(old.revision), + ..Default::default() + }, + assigned(F::Operator, "Guest operators"), + ], + )?; + let before = responsibility(&store, &guest, Vec::new())?; + assert_eq!( + before + .effective + .iter() + .find(|v| v.facet == F::ClaimedOwner as i32) + .ok_or("owner")? + .party + .as_ref() + .ok_or("party")? + .subject, + "Original team" + ); + let review = prepare(&store, &operator(), &guest, Some(&second))?; + assert_eq!(review.invalidated_inheritance.len(), 1); + assert!(review.invalidated_inheritance[0].contact.is_none()); + assert!(review.invalidated_inheritance[0].source.is_none()); + assert!(review.invalidated_inheritance[0].source_revision.is_none()); + assert_eq!( + review.invalidated_inheritance[0] + .party + .as_ref() + .ok_or("party")? + .subject, + "Original team" + ); + let mut hidden = operator(); + hidden.grants[0].read = Fields::Selected(vec![ + "name".into(), + "relations".into(), + "virtualization_role".into(), + ]); + assert!( + prepare(&store, &hidden, &guest, Some(&second))? + .invalidated_inheritance + .is_empty() + ); + record(&store, &operator(), &review)?; + let after = responsibility(&store, &guest, Vec::new())?; + // The read projection hides the no-longer-resolvable source reference, + // while preserving its INHERIT declaration and every independent facet. + let mut expected = before.assignments.clone(); + for assignment in &mut expected { + if assignment.source_relation.as_deref() == Some(old.relation_id.as_str()) { + assignment.source_relation = None; + assignment.source_revision = None; + } + } + assert_eq!(after.assignments, expected); + let owner = after + .effective + .iter() + .find(|v| v.facet == F::ClaimedOwner as i32) + .ok_or("owner")?; + assert_eq!(owner.status, S::SourceUnavailable as i32); + assert!(owner.party.is_none()); + assert_eq!( + after + .effective + .iter() + .find(|v| v.facet == F::Operator as i32) + .ok_or("operator")?, + before + .effective + .iter() + .find(|v| v.facet == F::Operator as i32) + .ok_or("operator")? + ); + Ok(()) +} + +// Placement is one conditional relation change. Guest identity/subtree and +// source-qualified addresses survive it; a later address report is independent. +#[test] +fn migration_keeps_guest_components_and_accepts_a_separate_ip_change() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + let os = child(&store, &guest, K::SoftwareInstallation)?; + let interface = child(&store, &os, K::Interface)?; + let service = child(&store, &os, K::Service)?; + address(&store, &interface, "192.0.2.30", 50)?; + let before: Vec<_> = [&guest, &os, &interface, &service] + .into_iter() + .map(|t| { + Ok(( + t.clone(), + store.asset(AssetRef::try_from(t).map_err(|_| Error::Invalid)?)?, + )) + }) + .collect::>()?; + let addresses = read(&store, &interface)?.current_addresses; + let usage = store.usage()?; + let review = prepare(&store, &operator(), &guest, Some(&second))?; + assert_eq!( + review.current_host.as_ref().and_then(|i| i.target.as_ref()), + Some(&first) + ); + assert_eq!(store.usage()?, usage, "review must not write"); + record(&store, &operator(), &review)?; + for (target, asset) in before { + assert_eq!(store.asset(AssetRef::try_from(&target)?)?, asset); + } + let current = placement(&store, &guest)?; + let previous = review.current.ok_or("placement")?; + assert_eq!(current.relation_id, previous.relation_id); + assert_eq!(current.revision, previous.revision + 1); + assert_eq!(current.to, Some(second.clone())); + assert_eq!(read(&store, &interface)?.current_addresses, addresses); + let unchanged = prepare(&store, &operator(), &guest, Some(&second))?; + let usage = store.usage()?; + record(&store, &operator(), &unchanged)?; + assert_eq!( + store.usage()?, + usage, + "repeated unchanged placement must not write" + ); + address(&store, &interface, "192.0.2.31", 60)?; + let updated = read(&store, &interface)?.current_addresses; + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].observations.len(), 1); + assert_eq!( + updated[0].observations[0] + .value + .as_ref() + .ok_or("address")? + .address, + "192.0.2.31" + ); + assert_eq!(placement(&store, &guest)?, current); + drop(store); + let store = Store::open(dir.path(), policy())?; + assert_eq!(placement(&store, &guest)?, current); + assert_eq!(read(&store, &interface)?.current_addresses, updated); + Ok(()) +} + +// A caller cannot disguise a hidden old host as "unplaced", use a local VM +// context for peer placement, or apply an obsolete role/revision review. +#[test] +fn placement_review_rechecks_old_host_context_role_and_revision() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + let mut hidden = operator(); + hidden.grants[0].scope = Scope::Targets(vec![ + AssetRef::try_from(&guest)?, + AssetRef::try_from(&second)?, + ]); + let usage = store.usage()?; + assert!(matches!( + prepare(&store, &hidden, &guest, None), + Err(Error::Denied) + )); + let review = prepare(&store, &operator(), &guest, Some(&second))?; + assert!(matches!( + record(&store, &hidden, &review), + Err(Error::Denied) + )); + let local = store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + focus: Some(guest.clone()), + ..Default::default() + }), + command: Some(Command::PrepareVmPlacement(api::PrepareVmPlacement { + vm: Some(guest.clone()), + destination: Some(second.clone()), + })), + }, + 100, + ); + assert!(matches!(local, Err(Error::Denied))); + assert_eq!(store.usage()?, usage); + call( + &store, + &operator(), + Command::Write(api::Write { + target: Some(second.clone()), + expected: Some(store.asset(AssetRef::try_from(&second)?)?.revision), + changes: vec![text_field( + "virtualization_role", + "VIRTUALIZATION_ROLE_NOT_HYPERVISOR", + )], + kind: K::SoftwareInstallation as i32, + ..Default::default() + }), + )?; + let usage = store.usage()?; + assert!(matches!( + record(&store, &operator(), &review), + Err(Error::Conflict) + )); + assert!(matches!( + prepare(&store, &operator(), &guest, Some(&second)), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, usage); + assert_eq!(placement(&store, &guest)?.to, Some(first)); + Ok(()) +} + +// A migration replaces the current placement, rather than adding a second +// accepted host and leaving the same VM visible under both hypervisors. +#[test] +fn second_current_host_requires_replacing_the_existing_placement() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + let before = store.usage()?; + assert!(matches!( + place(&store, &guest, &second), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert_eq!(placement(&store, &guest)?.to, Some(first)); + Ok(()) +} + +// In-flight effects keep their original target/placement. Reconciliation must +// settle them before recording another host or deleting the current relation. +#[test] +fn unresolved_effects_block_placement_changes_and_removal() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + let review = prepare(&store, &operator(), &guest, Some(&second))?; + let target = AssetRef::try_from(&guest)?; + let operation = store.accept(&plan(target, &[]), 100)?; + let usage = store.usage()?; + assert!(matches!( + prepare(&store, &operator(), &guest, Some(&second)), + Err(Error::UnsafeTransition) + )); + let mut moved = review.current.clone().ok_or("placement")?; + moved.to = Some(second.clone()); + assert!(matches!( + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(moved), + remove: false + }) + ), + Err(Error::UnsafeTransition) + )); + assert!(matches!( + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: review.current, + remove: true + }) + ), + Err(Error::UnsafeTransition) + )); + assert_eq!(store.usage()?, usage); + let lease = store.claim(operation, "worker", 101, 200)?; + store.reconcile( + &lease, + 1, + wire::OperationState::Cancelled, + wire::EffectOutcome::NotSent, + None, + 102, + )?; + let fresh = prepare(&store, &operator(), &guest, Some(&second))?; + record(&store, &operator(), &fresh)?; + assert_eq!(store.operation(operation)?.target, target); + assert_eq!(placement(&store, &guest)?.to, Some(second)); + Ok(()) +} + +// Two reviews share one root/relation basis. Exactly one can change it; the +// loser must obtain a new review rather than adding or overwriting a placement. +#[test] +fn concurrent_moves_accept_only_one_reviewed_destination() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + let third = hypervisor(&store, "Third host")?; + place(&store, &guest, &first)?; + let a = prepare(&store, &operator(), &guest, Some(&second))?; + let b = prepare(&store, &operator(), &guest, Some(&third))?; + let barrier = std::sync::Barrier::new(2); + let results = std::thread::scope(|scope| { + let workers: Vec<_> = [&a, &b] + .into_iter() + .map(|review| { + let (store, barrier) = (&store, &barrier); + scope.spawn(move || { + barrier.wait(); + record(store, &operator(), review) + }) + }) + .collect(); + workers + .into_iter() + .map(|worker| worker.join()) + .collect::>() + }); + let results: Vec<_> = results + .into_iter() + .map(|r| r.map_err(|_| "placement worker panicked")) + .collect::>()?; + assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|r| matches!(r, Err(Error::Conflict))) + .count(), + 1 + ); + let host = placement(&store, &guest)?.to.ok_or("host")?; + assert!(host == second || host == third); + Ok(()) +} diff --git a/docs/inventory.md b/docs/inventory.md index 9bcec35..ff45f01 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -370,6 +370,9 @@ an explicit clear. Invalid rows identify the failing row and mapped column witho echoing its value or persisting a rejected preview; invalid input cannot be partially accepted. A valid preview expires, and applying it rechecks the current application revision, actor scope and field types. Raw CSV history is not saved. +Identity correction does not retarget personal CSV approvals. A minimal binding +to the retired identity prevents an old source from silently updating the retained +resource or recreating a duplicate; reversing the correction restores that binding. Preparing a replacement atomically reclaims that importer's earlier review-only drafts and their rows. Failed preparation preserves the prior draft; another importer's previews remain under their own expiry and revision checks. @@ -531,6 +534,21 @@ and conflicting reports at the same source instant are refused. Each selected network section has its own current checkpoint, so accepting routes does not implicitly accept firewall data or prevent reviewing it separately. +For selected interfaces, review lists added addresses, removed addresses from a +complete refresh, and changed attributes. The comparison concerns the selected +source only. A partial refresh preserves omitted addresses and their original +observation dates. It never changes another source, an intended assignment or a +service's literal bind. Current-address read rights apply even when an existing +preview is reused. Preview custody stores the selected input, not another copy +of the derived comparison or an address history. + +Select two devices, VMs, OS installations or interfaces and choose **Compare +identity** to inspect their current permitted fields and source claims together. +Hardware and installation identifiers have separate types and namespaces; +matching reported digests do not establish authenticated identity or merge +resources. Comparison reads only the two selected subjects, without loading +their descendants or routing tables, and writes nothing. + Files are limited to 256 KiB, 128 findings and 2944 protobuf field visits before decoding. The remaining request budget accommodates explicit matches and context; small files with excessive nested fields are refused before generated allocations. @@ -573,6 +591,65 @@ Reports contain no environment, full commands, credentials or installed-package inventory. There is no implicit elevation, remote Docker connection, SSH delivery or resident installation. +## Reviewed identity correction + +Select two devices, VMs, OS installations or interfaces and choose **Compare +identity**. Matching reported identifiers support review but do not prove identity +or authorize a connection. Candidate lookup is also available before adopting +report findings, within the current permitted selection. Missing evidence, +cloned UUIDs, movable interfaces and reused addresses never trigger an automatic +merge or an access grant. + +Choose whether the records are separate, one chassis with distinct OS installs, +or duplicates of the same installation. Choose the retained identity and every +field resolution. Add only the OS/interface mappings actually established as +duplicates. Separate boots retain their own services, interfaces and current +addresses. Each physical attachment needs individual confirmation, followed by +ordinary containment, exclusivity and cycle validation. + +Native sources keep independent bindings even when their display labels match. +Manual or unresolved legacy namespace collisions require a reviewed rename. +Neither source relocation nor an address change rewrites literal service binds, +routes, firewall policy or VLAN/VRF membership. No grants, secrets, account control +or operation targets transfer. Explicit responsibility and external-effect +conflicts must be resolved first. + +The bounded preview changes no inventory. Accepting it checks the same working +context, current authority and installation revision, then commits atomically. +Its receipt supports an exact retry and a finite reversal window. Reversal +requires current rights and no subsequent accepted mutation; otherwise prepare a +fresh correction. Old references resolve read-only to the canonical incarnation +only when both original and canonical rights permit it. They never become write +or execution aliases. + +## Record a VM's new hypervisor + +After migrating a VM outside Omloom, open its details and choose **Change +hypervisor after migration**. Drag the VM card onto an enabled destination on +desktop, or use **Choose** on a phone or keyboard. Destination cards show +permitted hardware paths, addresses and exact identities. Searching and paging +stay within the selected enclosing context; choosing all permitted infrastructure +is an explicit action. + +Review the actual old and new host, then **Record and view VM**. This records +inventory placement only: it does not invoke a hypervisor migration. The same +VM, OS installations, interfaces, services and source mappings remain in place. +Current IPs can stay unchanged or be updated by a separate guest observation. +Service binds, routes, network/storage membership and grants are not inferred +from the destination. + +One VM has one accepted current hosting placement. Recording a move conditionally +updates that relation, rather than adding another host. A hidden old host, an +obsolete review, an ineligible destination, a cycle or unresolved guest effects +prevent acceptance. Ambiguous existing multi-host records require explicit +resolution through **Connections in broader scope**; upgrading never guesses +which host to retain. + +Direct ownership stays unchanged. The review identifies permitted inherited +facets bound to the old placement; those become source-unavailable after its +revision changes. Review their administrative source separately in the existing +responsibility editor. The new host never silently becomes the owner. + ## Verification ```sh diff --git a/docs/storage.md b/docs/storage.md index ddaa7d7..bb0c4eb 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -108,8 +108,8 @@ envelope, but are **application accounting**, not total filesystem allocation. Pinned detail and unresolved work count against their budgets. Admission fails atomically when it cannot fit; it does not evict safety state. -This unreleased storage format uses schema version 10. Supported versions 2 -through 9 upgrade in bounded restartable batches, including asset name/sort +This unreleased storage format uses schema version 12. Supported versions 2 +through 11 upgrade in bounded restartable batches, including asset name/sort projections from accepted intent, normalized IP ordering and source-scoped current observation facts with separate freshness and accounting. No inventory history is added. The upgrade keeps restore fences @@ -122,6 +122,28 @@ namespace/uniqueness records. A container owns at most one namespace fence and one fully qualified instance claim. Explicit inventory removal reclaims both; unlinking a runtime cannot relabel the container's lifetime. These records are current identity safety state, not a container history stream. +Version 11 adds current source-scoped host identity evidence. Each selected +subject has at most 32 claims across sources. Claims retain their observation +and boot basis, are protected by the `identity_evidence` field right, and are +removed with their subject. A missing claim does not refresh older evidence. +These claims support explicit comparison, never automatic identity or access +decisions. Pair comparison reads only two selected subjects and writes nothing. + +Version 12 indexes typed identity claims for scoped, explicit candidate lookup. +Native current observations use their accepted importer/configuration/subject +binding rather than a display label. This binding survives an approved identity +correction. The next ingestion qualifies a legacy source only when its original +mapping and collection window identify it unambiguously. Ambiguous legacy facts +keep their dates and are marked unresolved, without assignment to a new source. + +An identity correction captures only changed inventory/checkpoint rows, limited +to 128 rows and 256 KiB, inside the same transaction as its audit and retirement +fences. The inverse uses the configured detail lifetime and capacity admission; +it is never a subtree snapshot or default inventory history. Reversal requires +the exact accepted installation revision, current permissions and unchanged +external bindings. Any later mutation requires fresh review. Reversal drops +the bulky inverse and keeps only the retry receipt until ordinary detail cleanup. +Minimal retirement aliases remain safety checkpoints under checkpoint quotas. The host's durable worker calls bounded cleanup with a resumable cursor. Pin changes conflict with cleanup through database CAS. Cleanup reports logical diff --git a/proto/omloom/v1/workspace.proto b/proto/omloom/v1/workspace.proto index 82bb264..142153b 100644 --- a/proto/omloom/v1/workspace.proto +++ b/proto/omloom/v1/workspace.proto @@ -108,6 +108,35 @@ message WorkingContext { // or focus; omit for retrieval. The server rechecks membership at commit. optional uint64 expected = 3; } +// Read-only review of a placement already changed outside Omloom. This request +// never executes a hypervisor migration or changes the guest's addresses. +message PrepareVmPlacement { + // User-selected existing VM incarnation, never identified by its current IP. + omloom.v1.AssetRef vm = 1; + // Optional proposed enabled hypervisor installation. Omit to read the actual + // current placement before choosing a destination in the enclosing context. + optional omloom.v1.AssetRef destination = 2; +} +message VmPlacementReview { + // Current application basis, echoed as WorkingContext.expected on acceptance. + uint64 revision = 1; + // Exact permitted VM identity/name and accepted inventory revision. + Item vm = 2; + // Actual current placement and its conditional edit revision. Absent only + // when no placement exists; a hidden endpoint makes the review unavailable. + optional omloom.v1.Relation current = 3; + // Independently permitted actual old host, including a legacy device host. + optional Item current_host = 4; + // Independently permitted proposed enabled hypervisor installation. + optional Item destination = 5; + // Permitted currently resolved facets whose explicit old placement binding + // becomes unavailable after a move. Contacts and source references are omitted; + // no owner is copied to the destination. + repeated EffectiveResponsibility invalidated_inheritance = 6; + // Bounded permitted hardware-owner paths and local interface addresses for + // the actual old and proposed hosts, consistent with this review's revision. + repeated ItemContext contexts = 7; +} message Prepare { // User-selected presentation used to suggest applicable creation actions; // the server derives actual parent, kind and field permissions independently. @@ -503,6 +532,9 @@ message Detail { // responses contain only host_network and this cursor. Counts and filters // apply to the supplied page; coverage retains its source-collection meaning. optional string network_next = 12; + // Current selected layer-specific identity claims, filtered by identity_evidence + // field rights. Equality does not establish authenticated identity or access. + repeated HostIdentityObservation identity = 13; } message AddressObservation { @@ -517,6 +549,10 @@ message InterfaceAddressView { InterfaceAddresses coverage = 1; // Current facts with independent dates, including facts omitted from a partial refresh. repeated AddressObservation observations = 2; + // Opaque local source reference, independent of a possibly duplicated display label. + string source_id = 3; + // True when legacy facts could not be attributed to one reviewed native binding. + bool binding_unresolved = 4; } message NetworkObservation { // Exact source observation time, Unix milliseconds, independent of the latest scan checkpoint. @@ -542,6 +578,10 @@ message HostNetworkView { repeated NetworkObservation observations = 2; // Authorized current labels for services referenced by these facts, independent of relation pagination. repeated OwnerContext services = 3; + // Opaque local source reference, independent of its display label. + string source_id = 4; + // True when migrated current facts have unresolved native source provenance. + bool binding_unresolved = 5; } message FirewallSetting { // Reported application policy selector; never an effective per-port verdict. @@ -913,6 +953,170 @@ message HostFinding { bool addresses_complete = 6; // Known container runtime candidate, independent of deployment ownership. optional string runtime = 7; + // Selected layer-specific source claims, at most four. Missing evidence stays + // unknown. Claims support reviewed comparison, never authentication or grants. + repeated HostIdentityClaim identity = 8; +} +// What a source identifier describes. Boot sessions stay in HostReport.boot_basis +// and never become permanent host or OS identities. +enum HostIdentityKind { + HOST_IDENTITY_KIND_UNSPECIFIED = 0; + HOST_IDENTITY_KIND_HARDWARE_UUID = 1; + HOST_IDENTITY_KIND_INSTALLATION_ID = 2; + HOST_IDENTITY_KIND_INTERFACE_ID = 3; + HOST_IDENTITY_KIND_FACTORY_MAC = 4; +} +// Evidence limitations are independent of equality. Even a reported hardware +// UUID can be cloned or forged and cannot authorize a connection or correction. +enum HostIdentityQuality { + HOST_IDENTITY_QUALITY_UNSPECIFIED = 0; + HOST_IDENTITY_QUALITY_REPORTED = 1; + HOST_IDENTITY_QUALITY_MOVABLE = 2; + HOST_IDENTITY_QUALITY_UNRELIABLE = 3; +} +message HostIdentityClaim { + // Collector-selected subject layer and identifier meaning. + HostIdentityKind kind = 1; + // Actual identifier namespace, at most 80 UTF-8 bytes. Equal digests in + // different namespaces are not matching evidence. + string namespace = 2; + // Collector SHA-256 of the normalized identifier, 64 lowercase hex digits. + // This minimizes retained raw identifiers but is not a confidentiality claim. + string digest = 3; + // Actual source limitation. Unspecified quality is invalid, not trusted. + HostIdentityQuality quality = 4; +} +message HostIdentityObservation { + // Original source claim, including namespace and known limitations. + HostIdentityClaim claim = 1; + // Reviewed import configuration name, not the actor or a trusted issuer. + string source = 2; + // Actual claim collection instant, Unix milliseconds. A report omitting this + // claim cannot freshen it by merely updating the source checkpoint. + uint64 observed_at = 3; + // Boot/session basis of that collection. It is evidence, not an OS asset ID. + string boot_basis = 4; +} +message CompareHostIdentity { + // First explicitly selected accepted resource incarnation, never a name/IP. + omloom.v1.AssetRef left = 1; + // Second explicitly selected accepted resource incarnation in the same + // authorized working context. Comparison itself changes no identity. + omloom.v1.AssetRef right = 2; + // Explicit expansion of bounded affected relation metadata for correction planning. + bool include_relations = 3; +} +message HostIdentitySubject { + // Current accepted fields that the caller may read, without descendants, + // routes, firewall configuration or implied rights to those resources. + Item item = 1; + // Current permitted source claims with their original evidence windows. + repeated HostIdentityObservation identity = 2; + // Permitted current source labels; no source payloads or hidden counts. + repeated string sources = 3; + // Selected relation metadata, only when explicitly requested and both ends visible. + repeated omloom.v1.Relation relations = 4; +} +message HostIdentityComparison { + // Server-selected consistent application revision for both subjects. + uint64 revision = 1; + // Authorized first resource and selected identity evidence. + HostIdentitySubject left = 2; + // Authorized second resource and selected identity evidence. + HostIdentitySubject right = 3; +} +message FindIdentityCandidates { + // Selected current subject whose permitted claims supply exact indexed probes. + omloom.v1.AssetRef target = 1; + // Source claims supplied for explicit pre-adoption lookup, at most four. + // Mutually exclusive with target; matching never adopts or authenticates a host. + repeated HostIdentityClaim claims = 2; + // User-reviewed subject layer for claims-only lookup; absent when target is set. + omloom.v1.AssetKind kind = 3; +} +message IdentityCandidates { + // Bounded visible same-layer subjects; a match is evidence, never a merge. + repeated Item items = 1; +} +message ResolveIdentity { + // Original retired local reference. Resolution is read-only, never an effect target. + omloom.v1.AssetRef original = 1; +} +message ResolvedIdentity { + // Original reference retained for provenance in the UI. + omloom.v1.AssetRef original = 1; + // Current canonical resource with current rights applied independently. + Item canonical = 2; +} +enum IdentityDecision { + IDENTITY_DECISION_UNSPECIFIED = 0; + IDENTITY_DECISION_KEEP_SEPARATE = 1; + IDENTITY_DECISION_DISTINCT_INSTALLATIONS = 2; + IDENTITY_DECISION_DUPLICATE_INSTALLATION = 3; +} +message IdentityFieldChoice { + // Exact accepted field being resolved on the selected duplicate. + string name = 1; + // Explicit choice to use the duplicate's value instead of the retained value. + bool use_duplicate = 2; +} +message IdentitySourceChoice { + // Existing label group on this duplicate, shown by the server. A rename + // applies to all bindings with this label, without combining their data. + string before = 1; + // Reviewed display label after relocation; native binding identity is unchanged. + string after = 2; +} +message IdentityMapping { + // Explicitly selected duplicate incarnation to retire, never inferred from IP. + omloom.v1.AssetRef duplicate = 1; + // Existing canonical incarnation to retain at the same subject layer. + omloom.v1.AssetRef retained = 2; + // Explicit decisions for every field on the retiring identity, at most 32. + repeated IdentityFieldChoice fields = 3; + // Optional label changes, at most eight. Unbound source name collisions are refused; + // native sources retain independent bindings even when labels are identical. + repeated IdentitySourceChoice sources = 4; + // Individually confirmed physical attachment relation UUIDs, at most 128. + repeated string attachments = 5; +} +message IdentityCorrection { + // Operator conclusion; equality of identifiers never supplies this decision. + IdentityDecision decision = 1; + // Selected layer mappings, at most eight; never an instruction to merge descendants. + repeated IdentityMapping mappings = 2; + // Exact bounded preview to accept; mappings/decision must be absent on apply. + optional string preview = 3; + // Exact accepted correction to reverse within its window, after fresh checks. + optional string undo = 4; +} +message IdentityImpact { + // Existing relation before the proposed correction. + omloom.v1.Relation before = 1; + // Proposed relation; absent means an explicitly redundant/self attachment is removed. + optional omloom.v1.Relation after = 2; +} +message IdentityCorrectionView { + // Server-owned review identifier; no mutation has yet been accepted. + string id = 1; + // Installation revision fencing the complete reviewed change. + uint64 revision = 2; + // Selected explicit mappings, preserving the operator's field/source decisions. + repeated IdentityMapping mappings = 3; + // Bounded affected relations only, without loading descendant payloads. + repeated IdentityImpact relations = 4; + // Server-declared expiry of this preview in Unix milliseconds. + uint64 expires_at = 5; +} +message IdentityCorrectionApplied { + // Actual application revision after this correction or its explicit inverse. + uint64 revision = 1; + // Server-owned correction identifier for the bounded inverse window. + string id = 2; + // Expiry of the inverse window in Unix milliseconds; zero after reversal. + uint64 undo_until = 3; + // Canonical identities affected by the accepted operator decision. + repeated omloom.v1.AssetRef retained = 4; } message HostBind { // Source-local service candidate owning this observed bind. Empty means the @@ -1012,6 +1216,24 @@ message Address { // This is not observed election state and is absent for ordinary assignment. optional uint32 priority = 15; } +message AddressAttributeChange { + // Previously accepted current value from this exact interface/source. + CurrentAddress before = 1; + // Selected incoming value for the same address, without rewriting a service bind. + CurrentAddress after = 2; +} +message AddressDelta { + // Selected import configuration whose current facts are being compared. + string source = 1; + // Incoming addresses absent from this source's current interface observations. + repeated CurrentAddress added = 2; + // Current addresses omitted by an explicitly complete source refresh only. + repeated CurrentAddress removed = 3; + // Attribute changes on retained addresses; no duplicate unchanged values. + repeated AddressAttributeChange updated = 4; + // Source-declared completeness; false means omissions are retained. + bool complete = 5; +} message ImportRow { // One-based source row used to locate errors without losing input context. uint32 row = 1; @@ -1019,6 +1241,9 @@ message ImportRow { Write change = 2; // Field-specific actionable validation diagnostic; never a successful error envelope. string diagnostic = 3; + // Authorized source-local address differences derived for this bounded review. + // Absent for non-interface rows or when current-address reading is not allowed. + AddressDelta addresses = 4; } message Preview { // Server-generated bounded-lived preview identity, absent for invalid previews. @@ -1187,6 +1412,16 @@ message Request { ResponsibilityTransfer responsibility_transfer = 26; // Review and atomically accept selected native host observations. HostImport host_import = 27; + // Compare two selected identities without adopting, merging or writing. + CompareHostIdentity compare_host_identity = 28; + // Preview/apply/reverse an explicit bounded identity correction. + IdentityCorrection correct_identity = 29; + // Find bounded identity candidates within current permissions and working context. + FindIdentityCandidates find_identity_candidates = 30; + // Resolve a retired reference without transferring its old grants. + ResolveIdentity resolve_identity = 31; + // Review current/proposed VM placement without invoking an external effect. + PrepareVmPlacement prepare_vm_placement = 32; } } message Reply { @@ -1222,5 +1457,17 @@ message Reply { OwnerRequestState owner_request = 14; // Selected responsibility export or the result of one atomic reviewed import. ResponsibilityTransferState responsibility_transfer = 15; + // Consistent bounded comparison, not a correction or authority grant. + HostIdentityComparison host_identity_comparison = 16; + // Derived correction effects under current field and relation rights. + IdentityCorrectionView identity_correction = 17; + // Accepted correction receipt, not a grant or an external effect receipt. + IdentityCorrectionApplied identity_corrected = 18; + // Current authorized candidates; no hidden totals or candidate persistence. + IdentityCandidates identity_candidates = 19; + // Explicit reference provenance and the independently authorized current target. + ResolvedIdentity resolved_identity = 20; + // Current authorized placement and the independently reviewed destination. + VmPlacementReview vm_placement_review = 21; } } diff --git a/web/app/AddressDelta.vue b/web/app/AddressDelta.vue new file mode 100644 index 0000000..8800ffc --- /dev/null +++ b/web/app/AddressDelta.vue @@ -0,0 +1,14 @@ + + + diff --git a/web/app/App.vue b/web/app/App.vue index 24b7c98..6ba9537 100644 --- a/web/app/App.vue +++ b/web/app/App.vue @@ -3,6 +3,9 @@ import HostNetwork from './HostNetwork.vue'; import HostImportEditor from './HostImportEditor.vue'; import CategoryNav from './CategoryNav.vue'; import CurrentAddresses from './CurrentAddresses.vue'; +import HostIdentityEvidence from './HostIdentityEvidence.vue'; +import HostIdentityCompare from './HostIdentityCompare.vue'; +import VmPlacement from './VmPlacement.vue'; import ResponsibilityPanel from './ResponsibilityPanel.vue'; import AccountPanel from './AccountPanel.vue'; import { softwareFields } from './software-fields'; @@ -40,6 +43,34 @@ let returnQuery: LocationQuery | undefined; const detailHeading = ref(); const moreActions = ref(false); const selected = ref([]); const search = ref(''); +const identityPair = ref<{left:AssetRef;right:AssetRef;working:model.WorkingContext}>(); +const placementVm = ref(); +async function placementSaved(vm: AssetRef, host: AssetRef) { + placementVm.value = undefined; + await router.push({path:'/',query:{view:String(model.View.VIRTUALIZATION),scope:[host.assetId],scopeInc:[host.incarnation],asset:vm.assetId,incarnation:vm.incarnation}}); +} +const identityCandidates=ref();const findingCandidates=ref(false); +const resolvedAlias=ref(); +const comparable = computed(() => { + const items = page.value.items.filter(item => selected.value.includes(key(item.target))); + return items.length === 2 && items.every(item => item.target && [K.DEVICE, K.VIRTUAL_MACHINE, K.SOFTWARE_INSTALLATION, K.INTERFACE].includes(item.kind)); +}); +function compareIdentity() { + const items = page.value.items.filter(item => selected.value.includes(key(item.target))); + if (comparable.value && items[0]?.target && items[1]?.target) identityPair.value = {left:items[0].target,right:items[1].target,working:model.WorkingContext.create(working.value)}; +} +async function findIdentityCandidates(){ + const current=detail.value;if(!current?.item?.target||findingCandidates.value)return; + findingCandidates.value=true;error.value='';identityCandidates.value=undefined; + try{const value=await call({oneofKind:'findIdentityCandidates',findIdentityCandidates:model.FindIdentityCandidates.create({target:current.item.target})},enclosing.value);if(value.oneofKind!=='identityCandidates')throw Error('Candidate search is unavailable.');if(detail.value===current)identityCandidates.value=value.identityCandidates.items;} + catch(e){if(detail.value===current)error.value=String(e);}finally{findingCandidates.value=false;} +} +function compareCandidate(item:model.Item){const left=detail.value?.item?.target;if(left&&item.target)identityPair.value={left,right:item.target,working:model.WorkingContext.create(enclosing.value)};} +async function resolveIdentityReference(){ + if(typeof route.query.asset!=='string'||typeof route.query.incarnation!=='string')return; + try{const value=await call({oneofKind:'resolveIdentity',resolveIdentity:model.ResolveIdentity.create({original:{assetId:route.query.asset,incarnation:route.query.incarnation}})},enclosing.value);if(value.oneofKind!=='resolvedIdentity')throw Error('Reference resolution is unavailable.');resolvedAlias.value=value.resolvedIdentity;} + catch(e){error.value=String(e);} +} const viewSearch = ref(''); const matchingViews = computed(() => views.value.filter(v => v.name.toLocaleLowerCase().includes(viewSearch.value.toLocaleLowerCase()))); const presentations = [{ id: model.View.OVERVIEW, name: 'Overview', symbol: '▦' }, { id: model.View.NETWORK, name: 'Network', symbol: '⇄' }, { id: model.View.HARDWARE, name: 'Hardware', symbol: '▤' }, { id: model.View.VIRTUALIZATION, name: 'Virtualization', symbol: '◫' }, { id: model.View.SERVICES, name: 'Software & services', symbol: '◇' }]; @@ -133,6 +164,7 @@ const detailName = computed(() => display(detail.value?.item?.fields.find(f => f let generation = 0; let loadedQuery = ''; async function load() { + identityCandidates.value=undefined;resolvedAlias.value=undefined; const turn = ++generation; loading.value = true; error.value = ''; preparedActions.value = model.Actions.create(); const signature = JSON.stringify(model.Query.toJson(query.value)); @@ -210,6 +242,13 @@ async function open(target: AssetRef | undefined, selection: LocationQuery = rou if (detail.value?.item?.target && key(detail.value.item.target) !== key(target)) trail.push(JSON.stringify({ ...selection, trail: undefined })); await router.push({ path: '/', query: { ...selection, origin: selection.asset ? selection.origin : JSON.stringify({...selection,origin:undefined}), localSearch: undefined, localKind: undefined, localFilter: undefined, localCursor: undefined, trail: trail.length ? trail : undefined, asset: target.assetId, incarnation: target.incarnation } }); } +async function openResolvedIdentity() { + const original = resolvedAlias.value?.original; + const canonical = resolvedAlias.value?.canonical?.target; + if (!original || !canonical) return; + const scope = roots().map(root => key(root) === key(original) ? canonical : root); + await open(canonical, { ...route.query, scope: scope.map(root => root.assetId), scopeInc: scope.map(root => root.incarnation) }); +} async function closeDetail() { const trail = parameters('trail'); const parent = trail.pop(); if (parent) { @@ -312,13 +351,14 @@ onMounted(async () => { try { await metadata(); await load(); } catch (e) { erro

{{ currentTitle }}

SCOPEAll permitted infrastructure{{ detail?.item ? 'Local components and known facts' : 'Changing views preserves this scope.' }}
-

{{ notice }}

+

{{ notice }}

+

Original reference {{ resolvedAlias.original?.assetId }}. Current permitted identity: {{ display(resolvedAlias.canonical.fields.find(v=>v.name==='name')) }}.

{{ page.total.toString() }} resources · Loading…
{{ definitions.find(d => d.id === p.field)?.name ?? p.field }} = {{ display(p.value) }}{{model.ResponsibilityFacet[query.responsibility.facet]?.toLowerCase().replaceAll('_',' ')}}: {{query.responsibility.issuer}} / {{query.responsibility.subject}}
-
{{ selected.length }} selected on this page
-
-
{{ c.name }}Identity