ADR-047: the unifying decision that dissolves into per-ALPN ops (, ) with a marker on . Each openable ALPN registers its own ops with their own , , , and the marker. The field is replaced by (Sub/Pub). The generic ops (channel/close, channel/control, channel/resources/subscribe) stay, keyed by channel_id. Resolves Gaps A-G from the research findings (Gap B broker named out-of-scope for alkcall; Gap C relay wrapper is consumer concern; Gap D connection-owner allocates; Gap E extension trait; Gap F boolean marker on wire; Gap G ACL/ownership complementary). ADR-037 amended: dissolves; removed; generic ops stay; preview dropped from resources/subscribe. Spec docs updated: channel-operations.md (unified model, opener ledger, ACL flow), operation-registry.md (channel_open marker, ChannelOpenSpec), README.md (ADR-047), open-questions.md (OQ-31..38 resolved). Source changes: - spec.rs: ChannelOpenSpec struct, channel_open field on OperationSpec, with_channel_open builder, 3 tests - discovery.rs: spec_to_json emits channel_open boolean, operation_spec_schema includes channel_open, 2 tests - from_call.rs: rebuild_spec_for parses channel_open marker, derive_alpn_from_op_name helper, 6 tests Channels module (src/channels/, 10 files, ~2400 lines): - wire.rs: 8-byte chunk header (ChunkHeader, parse/write_header, read_header/write_chunk/write_eof async helpers), 12 tests - reassembly.rs: MpscRecvStream (tokio::mpsc::Receiver<Bytes> → AsyncRead), MpscSendStream (AsyncWrite → tokio::mpsc::Sender<Bytes>), REQ-CH-01 shutdown sentinel, REQ-CH-02 sender-drop EOF, 10 tests - mux.rs: MuxHandle (clone-able, register(channel_id)), MuxRunner (per-channel pump tasks, exits when handles drop), OpenerLedger (ADR-047 §7), 4 tests - manager.rs: ChannelManager (channel map, open_channel, install_channel_zero, route_payload, teardown_channel, clear_all), 11 tests - source.rs: ChannelBidiStreamSource (yield-once accept_bi), channel_source helper, 4 tests - adapter.rs: ChannelsAdapter (ProtocolHandler for alknet/channels, demux loop, install_channel_zero hook), 1 test - operations.rs: ChannelOperations (registers channel/close, channel/control, channel/resources/subscribe), ChannelCore (check_open/on_close wrappers), 4 tests - policy.rs: ChannelLifecyclePolicy trait, NoCap, PerIdentityChannelPolicy (default 256, per_identity_caps override), default_policy, 8 tests - env.rs: ChannelOperationEnv extension trait (ADR-047 §4), ChannelsSessionEnv impl, 2 tests - client.rs: ChannelClient (from_connection, call_open_op, take_call_connection), 1 test Verification: 432 tests pass (66 new channels + 10 marker + 356 existing), clippy clean, fmt clean, cargo doc generates. Cargo.toml: +bytes dependency.
274 lines
9.4 KiB
Rust
274 lines
9.4 KiB
Rust
//! `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<PeerId, usize>` + 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<String, usize>,
|
|
}
|
|
|
|
/// 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<String, usize>,
|
|
shared: Arc<Mutex<SharedCounts>>,
|
|
}
|
|
|
|
impl PerIdentityChannelPolicy {
|
|
/// Construct with a uniform cap. The cap is shared across every
|
|
/// channels connection this peer accepts (via the `Arc<Mutex<_>>`).
|
|
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<PeerId, cap>` — 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<String, usize>) -> 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<dyn ChannelLifecyclePolicy> {
|
|
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"
|
|
);
|
|
}
|
|
}
|