//! `ChannelLifecyclePolicy` — per-identity channel cap (ADR-041, //! amended by ADR-047 §7 — opener ledger, every teardown path). //! //! The cap is a **peer concern**: any accepting peer enforces the cap //! on its inbound channels, just as it enforces `AccessControl::check` //! on the open op. The cap is **symmetric** — both sides of a channels //! connection enforce their cap on the other's channels. //! //! The cap lives in `channels-call` (not `channels-core`) because //! `ChannelManager` is auth-blind by design (ADR-039). The identity is //! on `OperationContext`; the policy is consulted after //! `AccessControl::check` and before allocation, and on every teardown //! path keyed by the opener (from the per-connection opener ledger). use std::collections::HashMap; use std::sync::Arc; use parking_lot::Mutex; use crate::core::auth::Identity; /// Errors raised by `ChannelLifecyclePolicy::check_open`. #[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] pub enum ChannelError { #[error("too many channels for identity {identity}: {count} (cap {cap})")] TooManyChannels { identity: String, count: usize, cap: usize, }, } /// Per-identity channel lifecycle policy. Consulted by the open-op /// wrapper (after `AccessControl::check`, before allocation) and on /// every teardown path (after deallocation). Both have the identity /// via `OperationContext` (for open) or the opener ledger (for /// teardown — ADR-047 §7). pub trait ChannelLifecyclePolicy: Send + Sync + 'static { /// Before channel allocation. Deny with `channel:too_many_channels` /// when the identity is over its cap. The identity is the direct /// caller (the peer that opened this channels connection); /// `forwarded_for` is metadata and is NOT consulted (ADR-026). fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>; /// After channel deallocation. Decrement the per-identity count. /// Called on every teardown path (close received, close sent /// locally, handler exit, connection drop), keyed by the opener /// from the per-connection ledger (ADR-047 §7) — not the closer. fn on_close(&self, opener: &Identity); } /// No-cap policy — explicit opt-out for tests, POCs, and trusted /// single-peer deployments. Not the default (ADR-041). #[derive(Default)] pub struct NoCap; impl ChannelLifecyclePolicy for NoCap { fn check_open(&self, _identity: &Identity) -> Result<(), ChannelError> { Ok(()) } fn on_close(&self, _opener: &Identity) {} } /// Shared per-identity state for `PerIdentityChannelPolicy`. The /// `HashMap` + cap is constructed once per accepting /// peer and shared (via `Arc`) across every channels connection that /// peer accepts. The sharing is what makes the cap per-identity, not /// per-connection. #[derive(Default)] struct SharedCounts { counts: HashMap, } /// The default policy — 256 per identity (ADR-041). A channels- /// accepting peer that constructs `ChannelOperations::new(manager)` /// with no policy argument gets `PerIdentityChannelPolicy::new(256)`. /// The default is secure; opt-outs are explicit (`NoCap`). pub struct PerIdentityChannelPolicy { cap: usize, per_identity_caps: HashMap, shared: Arc>, } impl PerIdentityChannelPolicy { /// Construct with a uniform cap. The cap is shared across every /// channels connection this peer accepts (via the `Arc>`). pub fn new(cap: usize) -> Self { Self { cap, per_identity_caps: HashMap::new(), shared: Arc::new(Mutex::new(SharedCounts::default())), } } /// Construct with per-peer-role overrides. `mapping` is /// `HashMap` — a hub peer's cap may be set higher /// than a worker peer's cap (the "Relay consequence" in /// `channel-operations.md`). pub fn with_per_identity_caps(cap: usize, mapping: HashMap) -> Self { Self { cap, per_identity_caps: mapping, shared: Arc::new(Mutex::new(SharedCounts::default())), } } /// The current count for `identity` — for tests and observability. pub fn count_for(&self, identity: &Identity) -> usize { self.shared .lock() .counts .get(&identity.id) .copied() .unwrap_or(0) } fn effective_cap(&self, identity: &Identity) -> usize { self.per_identity_caps .get(&identity.id) .copied() .unwrap_or(self.cap) } } impl ChannelLifecyclePolicy for PerIdentityChannelPolicy { fn check_open(&self, identity: &Identity) -> Result<(), ChannelError> { let cap = self.effective_cap(identity); let mut shared = self.shared.lock(); let entry = shared.counts.entry(identity.id.clone()).or_insert(0); if *entry >= cap { return Err(ChannelError::TooManyChannels { identity: identity.id.clone(), count: *entry, cap, }); } *entry += 1; Ok(()) } fn on_close(&self, opener: &Identity) { let mut shared = self.shared.lock(); if let Some(entry) = shared.counts.get_mut(&opener.id) { if *entry > 0 { *entry -= 1; } if *entry == 0 { shared.counts.remove(&opener.id); } } } } /// The default per-identity cap (ADR-041). pub const DEFAULT_CHANNEL_CAP: usize = 256; /// Construct the default policy (`PerIdentityChannelPolicy::new(256)`). /// A channels-accepting peer that doesn't pass an explicit policy gets /// this — the default is secure. pub fn default_policy() -> Arc { Arc::new(PerIdentityChannelPolicy::new(DEFAULT_CHANNEL_CAP)) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn identity(id: &str) -> Identity { Identity { id: id.to_string(), scopes: vec![], resources: HashMap::new(), } } #[test] fn no_cap_allows_unlimited() { let policy = NoCap; let id = identity("alice"); for _ in 0..1000 { assert!(policy.check_open(&id).is_ok()); } policy.on_close(&id); } #[test] fn per_identity_allows_up_to_cap() { let policy = PerIdentityChannelPolicy::new(3); let id = identity("alice"); assert!(policy.check_open(&id).is_ok()); assert!(policy.check_open(&id).is_ok()); assert!(policy.check_open(&id).is_ok()); match policy.check_open(&id) { Err(ChannelError::TooManyChannels { count, cap, .. }) => { assert_eq!(count, 3); assert_eq!(cap, 3); } other => panic!("expected TooManyChannels, got {other:?}"), } } #[test] fn per_identity_on_close_decrements() { let policy = PerIdentityChannelPolicy::new(2); let id = identity("alice"); assert!(policy.check_open(&id).is_ok()); assert!(policy.check_open(&id).is_ok()); policy.on_close(&id); assert!(policy.check_open(&id).is_ok(), "decrement frees a slot"); } #[test] fn per_identity_counts_are_independent() { let policy = PerIdentityChannelPolicy::new(2); let alice = identity("alice"); let bob = identity("bob"); assert!(policy.check_open(&alice).is_ok()); assert!(policy.check_open(&alice).is_ok()); assert!(policy.check_open(&bob).is_ok()); assert!(policy.check_open(&bob).is_ok()); assert!(policy.check_open(&alice).is_err(), "alice at cap"); assert!(policy.check_open(&bob).is_err(), "bob at cap"); } #[test] fn per_identity_with_per_identity_caps_overrides_default() { let mut overrides = HashMap::new(); overrides.insert("hub".to_string(), 10); let policy = PerIdentityChannelPolicy::with_per_identity_caps(2, overrides); let hub = identity("hub"); let worker = identity("worker"); for _ in 0..10 { assert!(policy.check_open(&hub).is_ok(), "hub gets its own cap"); } assert!(policy.check_open(&hub).is_err(), "hub at its own cap"); assert!(policy.check_open(&worker).is_ok()); assert!(policy.check_open(&worker).is_ok()); assert!(policy.check_open(&worker).is_err(), "worker at default cap"); } #[test] fn per_identity_on_close_not_in_map_is_noop() { let policy = PerIdentityChannelPolicy::new(2); let id = identity("ghost"); policy.on_close(&id); assert_eq!(policy.count_for(&id), 0); } #[test] fn default_policy_is_256_per_identity() { let policy = default_policy(); let id = identity("alice"); for _ in 0..256 { assert!(policy.check_open(&id).is_ok(), "up to 256"); } assert!(policy.check_open(&id).is_err(), "257th denied"); } #[test] fn per_identity_on_close_removes_zero_count_entry() { let policy = PerIdentityChannelPolicy::new(2); let id = identity("alice"); policy.check_open(&id).expect("open"); policy.on_close(&id); assert_eq!(policy.count_for(&id), 0); assert!( !policy.shared.lock().counts.contains_key("alice"), "zero-count entry removed" ); } }