Skip to content

Commit e33ffd3

Browse files
mivertowskiclaude
andcommitted
feat(core): multi-tenant K2K isolation with AuditTag (v1.1)
Per spec section 3.5, two-tier tenancy model for audit firm workloads: 1. Per-kernel tenant_id = immutable security boundary (routing, quotas) 2. Per-message tenant_id + AuditTag{org_id, engagement_id} = audit trail Restructure: - k2k.rs (single file) -> k2k/ directory with mod.rs, tenant.rs, audit_tag.rs - All existing K2K tests continue passing New types: - TenantId (u64 alias, UNSPECIFIED_TENANT = 0 for fast path) - AuditTag { org_id, engagement_id } with Pod/Zeroable/rkyv derives - TenantQuota (concurrent kernels, GPU memory, msg rate, per-engagement budgets) - TenantInfo + TenantRegistry with audit sink integration - K2KSubBroker (per-tenant routing domain) - TenantStats for per-tenant observability K2KBroker: - HashMap<TenantId, K2KSubBroker> for isolation by construction - Pre-populates UNSPECIFIED_TENANT sub-broker = zero-cost single-tenant fast path - register_tenant(tenant_id, audit_tag, kernel_id) — tenant-aware registration - send_with_audit() — explicit audit_tag override - Cross-tenant send -> RingKernelError::TenantMismatch { from, to } + audit_cross_tenant() logs SecurityViolation to audit sink MessageEnvelope: - New fields: tenant_id: u64, audit_tag: AuditTag (default zero = anonymous) - New builders: with_tenant_id(), with_audit_tag() - Added Default impl - Broker re-stamps tenant_id + audit_tag on send (overwrites anonymous defaults) Added 32 new tests (13 multi-tenant broker + 12 tenant + 6 audit_tag tests). Total workspace tests: 1538 pass, 0 fail (up from 1506 baseline). Backward compatible: legacy register(kernel_id) unchanged, tenant_id=0 defaults. Zero-cost fast path for single-tenant deployments (pre-allocated UNSPECIFIED sub-broker, no HashMap miss). Downstream ripple: MessageEnvelope struct literals updated to use ..Default::default() across cpu, cuda, metal, wgpu, wavesim crates plus benches. PyDeliveryStatus gains TenantMismatch = 6 variant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a831843 commit e33ffd3

25 files changed

Lines changed: 1708 additions & 116 deletions

File tree

crates/ringkernel-core/src/dispatcher.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,8 @@ mod tests {
608608
header,
609609
payload: vec![],
610610
provenance: None,
611+
tenant_id: 0,
612+
audit_tag: crate::k2k::audit_tag::AuditTag::unspecified(),
611613
};
612614

613615
let result = dispatcher.dispatch(envelope).await;

crates/ringkernel-core/src/dlq.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,8 @@ mod tests {
286286
header: MessageHeader::new(1, 0, 1, 64, HlcTimestamp::now(1)),
287287
payload: vec![42u8; 64],
288288
provenance: None,
289+
tenant_id: 0,
290+
audit_tag: crate::k2k::audit_tag::AuditTag::unspecified(),
289291
}
290292
}
291293

crates/ringkernel-core/src/error.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,19 @@ pub enum RingKernelError {
200200
#[error("K2K delivery failed: {0}")]
201201
K2KDeliveryFailed(String),
202202

203+
/// Cross-tenant K2K send rejected.
204+
///
205+
/// Raised by the K2K broker when a kernel attempts to send a message to
206+
/// another kernel registered under a different tenant. Tenant isolation
207+
/// is the primary security boundary in multi-tenant deployments.
208+
#[error("cross-tenant K2K send rejected: from tenant {from} to tenant {to}")]
209+
TenantMismatch {
210+
/// Tenant ID of the sending kernel.
211+
from: u64,
212+
/// Tenant ID of the destination kernel.
213+
to: u64,
214+
},
215+
203216
// ===== Pub/Sub Errors =====
204217
/// Pub/sub error.
205218
#[error("pub/sub error: {0}")]
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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

Comments
 (0)