|
| 1 | +//! Audit tagging for K2K messages (multi-tenant cost/audit accounting). |
| 2 | +//! |
| 3 | +//! This module defines [`AuditTag`] — a two-level hierarchy used to attribute |
| 4 | +//! GPU work to a specific organization (audit firm) and engagement (audit |
| 5 | +//! project). Every K2K message envelope carries an `AuditTag` alongside its |
| 6 | +//! `TenantId`, enabling per-engagement cost tracking and audit trails without |
| 7 | +//! affecting the primary security boundary (which is [`TenantId`]). |
| 8 | +//! |
| 9 | +//! # Two-tier model |
| 10 | +//! |
| 11 | +//! - `TenantId` = security boundary (routing, isolation, quotas) |
| 12 | +//! - `AuditTag { org_id, engagement_id }` = observability / billing |
| 13 | +//! |
| 14 | +//! A single tenant may run many concurrent engagements (e.g. one audit firm |
| 15 | +//! conducting audits of several unrelated clients). Cross-engagement sends |
| 16 | +//! inside the same tenant are allowed (they are not a security boundary), but |
| 17 | +//! the envelope's `AuditTag` is preserved so cost is accounted correctly. |
| 18 | +//! |
| 19 | +//! # Example |
| 20 | +//! |
| 21 | +//! ```ignore |
| 22 | +//! use ringkernel_core::k2k::audit_tag::AuditTag; |
| 23 | +//! |
| 24 | +//! // Audit firm 42 running engagement 7 for client Acme Corp |
| 25 | +//! let tag = AuditTag::new(42, 7); |
| 26 | +//! println!("{}", tag); // "org=42 engagement=7" |
| 27 | +//! ``` |
| 28 | +//! |
| 29 | +//! [`TenantId`]: crate::k2k::tenant::TenantId |
| 30 | +
|
| 31 | +use bytemuck::{Pod, Zeroable}; |
| 32 | +use rkyv::{Archive, Deserialize, Serialize}; |
| 33 | +use zerocopy::{AsBytes, FromBytes, FromZeroes}; |
| 34 | + |
| 35 | +/// Two-level hierarchy for audit firm workload accounting. |
| 36 | +/// |
| 37 | +/// Every K2K [`MessageEnvelope`] carries an `AuditTag` so that GPU work can be |
| 38 | +/// attributed back to a specific engagement — critical for per-engagement |
| 39 | +/// billing and tamper-evident audit trails. |
| 40 | +/// |
| 41 | +/// `AuditTag::default()` returns `{ org_id: 0, engagement_id: 0 }`, denoting |
| 42 | +/// an unspecified / system-level engagement. This is the backward-compatible |
| 43 | +/// default for single-tenant deployments. |
| 44 | +/// |
| 45 | +/// # Layout |
| 46 | +/// |
| 47 | +/// Pod, Zeroable, AsBytes, FromBytes — safe for direct blit into GPU-shared |
| 48 | +/// memory. Rkyv-compatible for zero-copy serialization in message queues. |
| 49 | +/// |
| 50 | +/// [`MessageEnvelope`]: crate::message::MessageEnvelope |
| 51 | +#[derive( |
| 52 | + Debug, |
| 53 | + Clone, |
| 54 | + Copy, |
| 55 | + PartialEq, |
| 56 | + Eq, |
| 57 | + Hash, |
| 58 | + Default, |
| 59 | + AsBytes, |
| 60 | + FromBytes, |
| 61 | + FromZeroes, |
| 62 | + Pod, |
| 63 | + Zeroable, |
| 64 | + Archive, |
| 65 | + Serialize, |
| 66 | + Deserialize, |
| 67 | +)] |
| 68 | +#[repr(C)] |
| 69 | +pub struct AuditTag { |
| 70 | + /// Audit firm / organization ID. |
| 71 | + /// |
| 72 | + /// Stable across all engagements belonging to the same organization. |
| 73 | + /// `0` = unspecified (default). |
| 74 | + pub org_id: u64, |
| 75 | + /// Specific engagement / audit project ID. |
| 76 | + /// |
| 77 | + /// Distinct per billable unit of work. `0` = unspecified (default). |
| 78 | + pub engagement_id: u64, |
| 79 | +} |
| 80 | + |
| 81 | +impl AuditTag { |
| 82 | + /// Create a new `AuditTag`. |
| 83 | + #[inline] |
| 84 | + pub const fn new(org_id: u64, engagement_id: u64) -> Self { |
| 85 | + Self { |
| 86 | + org_id, |
| 87 | + engagement_id, |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + /// The unspecified / default audit tag (`{0, 0}`). |
| 92 | + /// |
| 93 | + /// Used in single-tenant deployments and as the default for legacy APIs |
| 94 | + /// that don't propagate audit information. |
| 95 | + #[inline] |
| 96 | + pub const fn unspecified() -> Self { |
| 97 | + Self { |
| 98 | + org_id: 0, |
| 99 | + engagement_id: 0, |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + /// Returns `true` if this tag is the default / unspecified tag (`{0, 0}`). |
| 104 | + #[inline] |
| 105 | + pub const fn is_unspecified(&self) -> bool { |
| 106 | + self.org_id == 0 && self.engagement_id == 0 |
| 107 | + } |
| 108 | + |
| 109 | + /// Returns the raw bytes of this tag (16 bytes, little-endian). |
| 110 | + /// |
| 111 | + /// Used when stamping audit tags into wire-format K2K message envelopes. |
| 112 | + #[inline] |
| 113 | + pub fn to_bytes(&self) -> [u8; 16] { |
| 114 | + let mut out = [0u8; 16]; |
| 115 | + out[..8].copy_from_slice(&self.org_id.to_le_bytes()); |
| 116 | + out[8..].copy_from_slice(&self.engagement_id.to_le_bytes()); |
| 117 | + out |
| 118 | + } |
| 119 | + |
| 120 | + /// Reconstruct an `AuditTag` from its raw byte representation. |
| 121 | + #[inline] |
| 122 | + pub fn from_bytes(bytes: [u8; 16]) -> Self { |
| 123 | + let org_id = u64::from_le_bytes([ |
| 124 | + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], |
| 125 | + ]); |
| 126 | + let engagement_id = u64::from_le_bytes([ |
| 127 | + bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], |
| 128 | + ]); |
| 129 | + Self { |
| 130 | + org_id, |
| 131 | + engagement_id, |
| 132 | + } |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +impl std::fmt::Display for AuditTag { |
| 137 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 138 | + write!(f, "org={} engagement={}", self.org_id, self.engagement_id) |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +#[cfg(test)] |
| 143 | +mod tests { |
| 144 | + use super::*; |
| 145 | + |
| 146 | + #[test] |
| 147 | + fn test_audit_tag_default() { |
| 148 | + let tag = AuditTag::default(); |
| 149 | + assert_eq!(tag.org_id, 0); |
| 150 | + assert_eq!(tag.engagement_id, 0); |
| 151 | + assert!(tag.is_unspecified()); |
| 152 | + } |
| 153 | + |
| 154 | + #[test] |
| 155 | + fn test_audit_tag_new() { |
| 156 | + let tag = AuditTag::new(42, 7); |
| 157 | + assert_eq!(tag.org_id, 42); |
| 158 | + assert_eq!(tag.engagement_id, 7); |
| 159 | + assert!(!tag.is_unspecified()); |
| 160 | + } |
| 161 | + |
| 162 | + #[test] |
| 163 | + fn test_audit_tag_display() { |
| 164 | + let tag = AuditTag::new(42, 7); |
| 165 | + assert_eq!(format!("{}", tag), "org=42 engagement=7"); |
| 166 | + } |
| 167 | + |
| 168 | + #[test] |
| 169 | + fn test_audit_tag_roundtrip_bytes() { |
| 170 | + let tag = AuditTag::new(0xDEADBEEF, 0xCAFEF00D); |
| 171 | + let bytes = tag.to_bytes(); |
| 172 | + let roundtrip = AuditTag::from_bytes(bytes); |
| 173 | + assert_eq!(tag, roundtrip); |
| 174 | + } |
| 175 | + |
| 176 | + #[test] |
| 177 | + fn test_audit_tag_hash_eq() { |
| 178 | + use std::collections::HashMap; |
| 179 | + let mut map: HashMap<AuditTag, u32> = HashMap::new(); |
| 180 | + map.insert(AuditTag::new(1, 1), 10); |
| 181 | + map.insert(AuditTag::new(1, 2), 20); |
| 182 | + assert_eq!(map.get(&AuditTag::new(1, 1)), Some(&10)); |
| 183 | + assert_eq!(map.get(&AuditTag::new(1, 2)), Some(&20)); |
| 184 | + assert_eq!(map.get(&AuditTag::new(1, 3)), None); |
| 185 | + } |
| 186 | + |
| 187 | + #[test] |
| 188 | + fn test_audit_tag_size() { |
| 189 | + // 2 x u64 = 16 bytes, no padding |
| 190 | + assert_eq!(std::mem::size_of::<AuditTag>(), 16); |
| 191 | + } |
| 192 | +} |
0 commit comments