use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::types::{
AuditReasonCode, AuditStep, AuditStepKind, AuditStepStatus, ChainAuditReport, SDK_VERSION,
};
pub const TENANT_CHAIN_VERSION: &str = "tenantChain.v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenantChainedRecord {
#[serde(rename = "rowAfterHash")]
pub row_after_hash: String,
#[serde(rename = "tenantChainPrev")]
pub tenant_chain_prev: Option<String>,
#[serde(rename = "tenantChainNext")]
pub tenant_chain_next: String,
#[serde(rename = "tenantChainSequence")]
pub tenant_chain_sequence: String,
}
pub fn canonicalise_tenant_chain_link(
prev: Option<&str>,
row_after_hash: &str,
sequence: &str,
) -> String {
let prev_str: &str = match prev {
Some(s) if !s.is_empty() => s,
_ => "-",
};
format!(
"{}|{}|{}|{}",
TENANT_CHAIN_VERSION, prev_str, row_after_hash, sequence
)
}
pub fn genesis_chain_tip(org_id: &str) -> String {
format!("provenance.{}.{}", TENANT_CHAIN_VERSION, org_id)
}
fn sha256_hex(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
let bytes = hasher.finalize();
let mut out = String::with_capacity(64);
for b in bytes.iter() {
out.push_str(&format!("{:02x}", b));
}
out
}
pub fn verify_tenant_chain(
records: &[TenantChainedRecord],
expected_genesis: &str,
) -> ChainAuditReport {
let verified_at = chrono_now_iso();
let mut steps: Vec<AuditStep> = Vec::new();
if records.is_empty() {
steps.push(AuditStep {
target: "records".into(),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::MalformedPack),
message: "tenant chain audit received an empty record set — nothing to verify".into(),
detail: None,
});
return ChainAuditReport {
status: AuditStepStatus::Invalid,
verified_at,
sdk_version: SDK_VERSION.to_string(),
record_count: 0,
steps,
};
}
let first = &records[0];
let first_prev = first.tenant_chain_prev.as_deref().unwrap_or("");
if first_prev != expected_genesis {
steps.push(AuditStep {
target: "records[0].tenantChainPrev".into(),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::ChainLinkMismatch),
message:
"first record's tenantChainPrev does not equal the expected genesis seed — chain is rooted at an unknown prior tip"
.into(),
detail: Some(serde_json::json!({
"expected": expected_genesis,
"actual": first_prev,
})),
});
} else {
steps.push(AuditStep {
target: "records[0].tenantChainPrev".into(),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Valid,
reason: None,
message: "genesis prev seed matches the expected tenant seed".into(),
detail: None,
});
}
let mut prev_sequence: Option<u64> = None;
for (i, curr) in records.iter().enumerate() {
let curr_sequence: u64 = match curr.tenant_chain_sequence.parse::<u64>() {
Ok(n) => n,
Err(_) => {
steps.push(AuditStep {
target: format!("records[{}].tenantChainSequence", i),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::MalformedPack),
message: format!(
"tenantChainSequence at index {} is not a valid integer string",
i
),
detail: Some(serde_json::json!({
"value": curr.tenant_chain_sequence,
})),
});
continue;
}
};
if let Some(prev_seq) = prev_sequence {
if curr_sequence != prev_seq + 1 {
steps.push(AuditStep {
target: format!("records[{}].tenantChainSequence", i),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::ChainOutOfOrder),
message: format!(
"tenantChainSequence at index {} is {}, expected {} (gaps or duplicates indicate inserted/dropped rows)",
i,
curr_sequence,
prev_seq + 1,
),
detail: Some(serde_json::json!({
"expected": (prev_seq + 1).to_string(),
"actual": curr_sequence.to_string(),
})),
});
}
}
prev_sequence = Some(curr_sequence);
if i > 0 {
let prev = &records[i - 1];
let curr_prev = curr.tenant_chain_prev.as_deref().unwrap_or("");
if curr_prev != prev.tenant_chain_next {
steps.push(AuditStep {
target: format!("records[{}].tenantChainPrev", i),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::ChainLinkMismatch),
message: format!(
"record[{}].tenantChainPrev does not equal record[{}].tenantChainNext — chain link broken",
i,
i - 1
),
detail: Some(serde_json::json!({
"expected": prev.tenant_chain_next,
"actual": curr_prev,
})),
});
} else {
steps.push(AuditStep {
target: format!("records[{}].tenantChainPrev", i),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Valid,
reason: None,
message: format!("record[{}] correctly chains off record[{}]", i, i - 1),
detail: None,
});
}
}
let expected_next = sha256_hex(&canonicalise_tenant_chain_link(
curr.tenant_chain_prev.as_deref(),
&curr.row_after_hash,
&curr_sequence.to_string(),
));
if expected_next != curr.tenant_chain_next {
steps.push(AuditStep {
target: format!("records[{}].tenantChainNext", i),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::ChainLinkMismatch),
message: format!(
"record[{}].tenantChainNext does not equal the recomputed link — value was tampered with after write",
i
),
detail: Some(serde_json::json!({
"expected": expected_next,
"actual": curr.tenant_chain_next,
})),
});
} else {
steps.push(AuditStep {
target: format!("records[{}].tenantChainNext", i),
kind: AuditStepKind::ChainLink,
status: AuditStepStatus::Valid,
reason: None,
message: format!("record[{}].tenantChainNext matches the recomputed link", i),
detail: None,
});
}
}
let any_invalid = steps.iter().any(|s| matches!(s.status, AuditStepStatus::Invalid));
ChainAuditReport {
status: if any_invalid {
AuditStepStatus::Invalid
} else {
AuditStepStatus::Valid
},
verified_at,
sdk_version: SDK_VERSION.to_string(),
record_count: records.len(),
steps,
}
}
fn chrono_now_iso() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}