From 4361bddfaf2d7388aab1d90a5d7fd54f5db878e7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 29 Oct 2026 19:11:44 +0200 Subject: [PATCH 01/25] feat(inventory): record host identity evidence --- Cargo.lock | 5 +- crates/omloom-observe/Cargo.toml | 1 + crates/omloom-observe/src/main.rs | 25 ++- crates/omloom-observe/src/native.rs | 110 +++++++++--- crates/omloom-observe/src/native/tests.rs | 50 ++++++ crates/omloom-store/src/disk/workspace.rs | 1 + .../omloom-store/src/disk/workspace/assets.rs | 5 + .../src/disk/workspace/current_facts.rs | 3 + .../src/disk/workspace/host_identity.rs | 151 ++++++++++++++++ .../src/disk/workspace/host_import.rs | 13 ++ .../tests/workspace/host_import.rs | 117 +++++++++++++ proto/omloom/v1/workspace.proto | 46 +++++ web/app/App.vue | 2 + web/app/HostIdentityEvidence.vue | 14 ++ web/src/generated/omloom/v1/workspace.ts | 163 +++++++++++++++++- 15 files changed, 673 insertions(+), 33 deletions(-) create mode 100644 crates/omloom-observe/src/native/tests.rs create mode 100644 crates/omloom-store/src/disk/workspace/host_identity.rs create mode 100644 web/app/HostIdentityEvidence.vue diff --git a/Cargo.lock b/Cargo.lock index 6dedf05..ac18b48 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]] 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..221fcc9 --- /dev/null +++ b/crates/omloom-observe/src/native/tests.rs @@ -0,0 +1,50 @@ +use super::*; + +// Identical normalized claims compare across boots without storing the raw UUID. +// 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); + 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/src/disk/workspace.rs b/crates/omloom-store/src/disk/workspace.rs index e705510..3ed1806 100644 --- a/crates/omloom-store/src/disk/workspace.rs +++ b/crates/omloom-store/src/disk/workspace.rs @@ -11,6 +11,7 @@ mod assets; mod current_facts; mod definitions; mod exchange; +mod host_identity; mod host_import; mod interface_facts; mod network; diff --git a/crates/omloom-store/src/disk/workspace/assets.rs b/crates/omloom-store/src/disk/workspace/assets.rs index 92e6e1c..ed9a563 100644 --- a/crates/omloom-store/src/disk/workspace/assets.rs +++ b/crates/omloom-store/src/disk/workspace/assets.rs @@ -1286,6 +1286,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)?; 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/host_identity.rs b/crates/omloom-store/src/disk/workspace/host_identity.rs new file mode 100644 index 0000000..744dbd7 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/host_identity.rs @@ -0,0 +1,151 @@ +//! 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"; + +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([ + ( + "identity_lookup", + text(format!( + "{}/{}/{}", + claim.kind, claim.namespace, claim.digest + )), + ), + ("source", text(source.name)), + ("mapping", text(source.mapping)), + ("observed", int(source.observed)?), + ("boot", text(source.boot)), + ("payload", Value::Binary(payload)), + ])); + 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, "boot")?, + }) + }) + .collect() + } +} diff --git a/crates/omloom-store/src/disk/workspace/host_import.rs b/crates/omloom-store/src/disk/workspace/host_import.rs index b33a20c..9ebdece 100644 --- a/crates/omloom-store/src/disk/workspace/host_import.rs +++ b/crates/omloom-store/src/disk/workspace/host_import.rs @@ -34,6 +34,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) @@ -323,6 +324,18 @@ impl Tx<'_> { complete: node.addresses_complete, })?; } + self.accept_host_identity( + context, + target, + kind, + super::host_identity::Source { + mapping: &key, + name: &request.source, + observed: report.observed_at, + boot: &report.boot_basis, + }, + &node.identity, + )?; let mut data = target_props(target); data.insert("source".into(), text(&request.source)); // One current checkpoint fences stale replay and same-instant diff --git a/crates/omloom-store/tests/workspace/host_import.rs b/crates/omloom-store/tests/workspace/host_import.rs index 74e6f74..1939287 100644 --- a/crates/omloom-store/tests/workspace/host_import.rs +++ b/crates/omloom-store/tests/workspace/host_import.rs @@ -53,6 +53,123 @@ fn draft() -> api::HostImport { network_parts: Vec::new(), } } + +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(()) +} fn preview(store: &Store, request: api::HostImport) -> Result { let Reply::Preview(value) = call(store, &operator(), Command::HostImport(request))? else { return Err(Error::Corrupt); diff --git a/proto/omloom/v1/workspace.proto b/proto/omloom/v1/workspace.proto index 82bb264..09b7271 100644 --- a/proto/omloom/v1/workspace.proto +++ b/proto/omloom/v1/workspace.proto @@ -503,6 +503,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 { @@ -913,6 +916,49 @@ 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 HostBind { // Source-local service candidate owning this observed bind. Empty means the diff --git a/web/app/App.vue b/web/app/App.vue index 24b7c98..05a78af 100644 --- a/web/app/App.vue +++ b/web/app/App.vue @@ -3,6 +3,7 @@ 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 ResponsibilityPanel from './ResponsibilityPanel.vue'; import AccountPanel from './AccountPanel.vue'; import { softwareFields } from './software-fields'; @@ -332,6 +333,7 @@ onMounted(async () => { try { await metadata(); await load(); } catch (e) { erro

Address allocation

Shared group:

Participant coverage is restricted. Missing participants do not prove the allocation is free.

  • · Intended priority {{ p.priority }}
+

Showing one bounded evidence page. Observation counts and filters apply to this page.

diff --git a/web/app/HostIdentityEvidence.vue b/web/app/HostIdentityEvidence.vue new file mode 100644 index 0000000..119dde2 --- /dev/null +++ b/web/app/HostIdentityEvidence.vue @@ -0,0 +1,14 @@ + + diff --git a/web/src/generated/omloom/v1/workspace.ts b/web/src/generated/omloom/v1/workspace.ts index e7cf071..b15b694 100644 --- a/web/src/generated/omloom/v1/workspace.ts +++ b/web/src/generated/omloom/v1/workspace.ts @@ -1068,6 +1068,13 @@ export interface Detail { * @generated from protobuf field: optional string network_next = 12 */ networkNext?: string; + /** + * Current selected layer-specific identity claims, filtered by identity_evidence + * field rights. Equality does not establish authenticated identity or access. + * + * @generated from protobuf field: repeated omloom.workspace.v1.HostIdentityObservation identity = 13 + */ + identity: HostIdentityObservation[]; } /** * @generated from protobuf message omloom.workspace.v1.AddressObservation @@ -2191,6 +2198,74 @@ export interface HostFinding { * @generated from protobuf field: optional string runtime = 7 */ runtime?: string; + /** + * Selected layer-specific source claims, at most four. Missing evidence stays + * unknown. Claims support reviewed comparison, never authentication or grants. + * + * @generated from protobuf field: repeated omloom.workspace.v1.HostIdentityClaim identity = 8 + */ + identity: HostIdentityClaim[]; +} +/** + * @generated from protobuf message omloom.workspace.v1.HostIdentityClaim + */ +export interface HostIdentityClaim { + /** + * Collector-selected subject layer and identifier meaning. + * + * @generated from protobuf field: omloom.workspace.v1.HostIdentityKind kind = 1 + */ + kind: HostIdentityKind; + /** + * Actual identifier namespace, at most 80 UTF-8 bytes. Equal digests in + * different namespaces are not matching evidence. + * + * @generated from protobuf field: string namespace = 2 + */ + namespace: string; + /** + * Collector SHA-256 of the normalized identifier, 64 lowercase hex digits. + * This minimizes retained raw identifiers but is not a confidentiality claim. + * + * @generated from protobuf field: string digest = 3 + */ + digest: string; + /** + * Actual source limitation. Unspecified quality is invalid, not trusted. + * + * @generated from protobuf field: omloom.workspace.v1.HostIdentityQuality quality = 4 + */ + quality: HostIdentityQuality; +} +/** + * @generated from protobuf message omloom.workspace.v1.HostIdentityObservation + */ +export interface HostIdentityObservation { + /** + * Original source claim, including namespace and known limitations. + * + * @generated from protobuf field: omloom.workspace.v1.HostIdentityClaim claim = 1 + */ + claim?: HostIdentityClaim; + /** + * Reviewed import configuration name, not the actor or a trusted issuer. + * + * @generated from protobuf field: string source = 2 + */ + source: string; + /** + * Actual claim collection instant, Unix milliseconds. A report omitting this + * claim cannot freshen it by merely updating the source checkpoint. + * + * @generated from protobuf field: uint64 observed_at = 3 + */ + observedAt: bigint; + /** + * Boot/session basis of that collection. It is evidence, not an OS asset ID. + * + * @generated from protobuf field: string boot_basis = 4 + */ + bootBasis: string; } /** * @generated from protobuf message omloom.workspace.v1.HostBind @@ -3475,6 +3550,58 @@ export enum AddressState { */ DEPRECATED = 2 } +/** + * What a source identifier describes. Boot sessions stay in HostReport.boot_basis + * and never become permanent host or OS identities. + * + * @generated from protobuf enum omloom.workspace.v1.HostIdentityKind + */ +export enum HostIdentityKind { + /** + * @generated from protobuf enum value: HOST_IDENTITY_KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: HOST_IDENTITY_KIND_HARDWARE_UUID = 1; + */ + HARDWARE_UUID = 1, + /** + * @generated from protobuf enum value: HOST_IDENTITY_KIND_INSTALLATION_ID = 2; + */ + INSTALLATION_ID = 2, + /** + * @generated from protobuf enum value: HOST_IDENTITY_KIND_INTERFACE_ID = 3; + */ + INTERFACE_ID = 3, + /** + * @generated from protobuf enum value: HOST_IDENTITY_KIND_FACTORY_MAC = 4; + */ + 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. + * + * @generated from protobuf enum omloom.workspace.v1.HostIdentityQuality + */ +export enum HostIdentityQuality { + /** + * @generated from protobuf enum value: HOST_IDENTITY_QUALITY_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: HOST_IDENTITY_QUALITY_REPORTED = 1; + */ + REPORTED = 1, + /** + * @generated from protobuf enum value: HOST_IDENTITY_QUALITY_MOVABLE = 2; + */ + MOVABLE = 2, + /** + * @generated from protobuf enum value: HOST_IDENTITY_QUALITY_UNRELIABLE = 3; + */ + UNRELIABLE = 3 +} /** * @generated from protobuf enum omloom.workspace.v1.HostNetworkPart */ @@ -3949,7 +4076,8 @@ class Detail$Type extends MessageType { { no: 9, name: "host_network", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => HostNetworkView }, { no: 10, name: "current_addresses", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => InterfaceAddressView }, { no: 11, name: "relations_next", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, - { no: 12, name: "network_next", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } + { no: 12, name: "network_next", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 13, name: "identity", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => HostIdentityObservation } ]); } } @@ -4440,7 +4568,8 @@ class HostFinding$Type extends MessageType { { no: 4, name: "fields", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => Field }, { no: 5, name: "addresses", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => CurrentAddress }, { no: 6, name: "addresses_complete", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, - { no: 7, name: "runtime", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } + { no: 7, name: "runtime", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 8, name: "identity", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => HostIdentityClaim } ]); } } @@ -4449,6 +4578,36 @@ class HostFinding$Type extends MessageType { */ export const HostFinding = new HostFinding$Type(); // @generated message type with reflection information, may provide speed optimized methods +class HostIdentityClaim$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.HostIdentityClaim", [ + { no: 1, name: "kind", kind: "enum", T: () => ["omloom.workspace.v1.HostIdentityKind", HostIdentityKind, "HOST_IDENTITY_KIND_"] }, + { no: 2, name: "namespace", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "digest", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 4, name: "quality", kind: "enum", T: () => ["omloom.workspace.v1.HostIdentityQuality", HostIdentityQuality, "HOST_IDENTITY_QUALITY_"] } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.HostIdentityClaim + */ +export const HostIdentityClaim = new HostIdentityClaim$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class HostIdentityObservation$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.HostIdentityObservation", [ + { no: 1, name: "claim", kind: "message", T: () => HostIdentityClaim }, + { no: 2, name: "source", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "observed_at", kind: "scalar", T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 4, name: "boot_basis", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.HostIdentityObservation + */ +export const HostIdentityObservation = new HostIdentityObservation$Type(); +// @generated message type with reflection information, may provide speed optimized methods class HostBind$Type extends MessageType { constructor() { super("omloom.workspace.v1.HostBind", [ From 7b69f7b4c2a6b35d5c7c5f5fb4b7e2e7a0aba27a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 29 Oct 2026 19:20:48 +0200 Subject: [PATCH 02/25] feat(inventory): compare host identity --- crates/omloom-store/src/disk.rs | 2 +- crates/omloom-store/src/disk/workspace.rs | 5 + .../src/disk/workspace/host_identity.rs | 86 +++++++++++-- .../tests/workspace/host_import.rs | 77 +++++++++++ docs/storage.md | 10 +- proto/omloom/v1/workspace.proto | 26 ++++ web/app/App.vue | 13 +- web/app/HostIdentityCompare.vue | 43 +++++++ web/browser/inventory.spec.mjs | 41 ++++++ web/src/generated/omloom/v1/workspace.ts | 121 +++++++++++++++++- 10 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 web/app/HostIdentityCompare.vue diff --git a/crates/omloom-store/src/disk.rs b/crates/omloom-store/src/disk.rs index 79dcb99..e66aed4 100644 --- a/crates/omloom-store/src/disk.rs +++ b/crates/omloom-store/src/disk.rs @@ -22,7 +22,7 @@ use std::{ type Properties = BTreeMap; type Result = std::result::Result; const ROOT: &str = "installation"; -const SCHEMA_VERSION: u64 = 10; +const SCHEMA_VERSION: u64 = 11; const MAX_CHILDREN: usize = 2048; mod inventory; mod lifecycle; diff --git a/crates/omloom-store/src/disk/workspace.rs b/crates/omloom-store/src/disk/workspace.rs index 3ed1806..b5ea3b3 100644 --- a/crates/omloom-store/src/disk/workspace.rs +++ b/crates/omloom-store/src/disk/workspace.rs @@ -307,6 +307,11 @@ 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::Export(export) => self.workspace_export(context, export), C::Network(network) => self.workspace_network( context, diff --git a/crates/omloom-store/src/disk/workspace/host_identity.rs b/crates/omloom-store/src/disk/workspace/host_identity.rs index 744dbd7..f2ec137 100644 --- a/crates/omloom-store/src/disk/workspace/host_identity.rs +++ b/crates/omloom-store/src/disk/workspace/host_identity.rs @@ -106,17 +106,10 @@ impl Tx<'_> { } let mut data = target_props(target); data.extend(props([ - ( - "identity_lookup", - text(format!( - "{}/{}/{}", - claim.kind, claim.namespace, claim.digest - )), - ), ("source", text(source.name)), - ("mapping", text(source.mapping)), + ("binding", text(source.mapping)), ("observed", int(source.observed)?), - ("boot", text(source.boot)), + ("vtext", text(source.boot)), ("payload", Value::Binary(payload)), ])); self.put_projection(&key, &parent, Class::Inventory, data, old.as_ref())?; @@ -143,9 +136,82 @@ impl Tx<'_> { ), source: string(&row, "source")?, observed_at: number(&row, "observed")?, - boot_basis: string(&row, "boot")?, + 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)?, + }) + } +} + +impl Store { + 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 comparison = api::HostIdentityComparison { + revision, + left: Some(tx.identity_subject(context, left)?), + right: Some(tx.identity_subject(context, right)?), + }; + tx.commit()?; + if self.usage()?.revision != revision { + return Err(Error::Expired); + } + Ok(reply(api::reply::Result::HostIdentityComparison( + comparison, + ))) + } } diff --git a/crates/omloom-store/tests/workspace/host_import.rs b/crates/omloom-store/tests/workspace/host_import.rs index 1939287..561a814 100644 --- a/crates/omloom-store/tests/workspace/host_import.rs +++ b/crates/omloom-store/tests/workspace/host_import.rs @@ -170,6 +170,83 @@ fn host_identity_rejects_layer_forgery_and_same_instant_equivocation() -> TestRe 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()), + }; + 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(()) +} fn preview(store: &Store, request: api::HostImport) -> Result { let Reply::Preview(value) = call(store, &operator(), Command::HostImport(request))? else { return Err(Error::Corrupt); diff --git a/docs/storage.md b/docs/storage.md index ddaa7d7..659a896 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 11. Supported versions 2 +through 10 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,12 @@ 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. 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 09b7271..b8566cb 100644 --- a/proto/omloom/v1/workspace.proto +++ b/proto/omloom/v1/workspace.proto @@ -960,6 +960,28 @@ message HostIdentityObservation { // 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; +} +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; +} +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 HostBind { // Source-local service candidate owning this observed bind. Empty means the // kernel bind was seen without a qualified owner; it cannot adopt a service. @@ -1233,6 +1255,8 @@ 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; } } message Reply { @@ -1268,5 +1292,7 @@ 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; } } diff --git a/web/app/App.vue b/web/app/App.vue index 05a78af..03c1e4f 100644 --- a/web/app/App.vue +++ b/web/app/App.vue @@ -4,6 +4,7 @@ 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 ResponsibilityPanel from './ResponsibilityPanel.vue'; import AccountPanel from './AccountPanel.vue'; import { softwareFields } from './software-fields'; @@ -41,6 +42,15 @@ let returnQuery: LocationQuery | undefined; const detailHeading = ref(); const moreActions = ref(false); const selected = ref([]); const search = ref(''); +const identityPair = ref<[AssetRef, AssetRef]>(); +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 = [items[0].target, items[1].target]; +} 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: '◇' }]; @@ -317,7 +327,7 @@ onMounted(async () => { try { await metadata(); await load(); } catch (e) { erro
{{ 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
+
{{ selected.length }} selected on this page
{{ c.name }}Identity