From a27ce257e66696108d9e5c6e9b8aa3daf31fcbdd Mon Sep 17 00:00:00 2001 From: GoDeskio Date: Thu, 5 Nov 2026 18:42:36 +0800 Subject: [PATCH] feat: land tenancy for PR #22 --- crates/encryptoglyph/src/lib.rs | 1 + crates/encryptoglyph/src/tenancy.rs | 112 ++++++++++++++++++++++++++ crates/encryptoglyph/tests/tenancy.rs | 34 ++++++++ 3 files changed, 147 insertions(+) create mode 100644 crates/encryptoglyph/src/tenancy.rs create mode 100644 crates/encryptoglyph/tests/tenancy.rs diff --git a/crates/encryptoglyph/src/lib.rs b/crates/encryptoglyph/src/lib.rs index 563304e..b8ae5fe 100644 --- a/crates/encryptoglyph/src/lib.rs +++ b/crates/encryptoglyph/src/lib.rs @@ -28,6 +28,7 @@ pub mod sign; pub mod ff1; pub mod pq; pub mod policy; +pub mod tenancy; pub const MAGIC: &[u8; 2] = b"EG"; pub const VERSION_LEGACY: u8 = 1; diff --git a/crates/encryptoglyph/src/tenancy.rs b/crates/encryptoglyph/src/tenancy.rs new file mode 100644 index 0000000..781bb52 --- /dev/null +++ b/crates/encryptoglyph/src/tenancy.rs @@ -0,0 +1,112 @@ +//! Multi-tenant namespaces, quotas and per-tenant rate limits (backlog #13, MSP model). +//! Namespaces are hierarchical (`msp/acme/eu`), and a child's limits can never exceed its +//! parent's. Ops are charged to the namespace and all of its ancestors. The clock is injected, +//! so tests are deterministic. +use crate::Error; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, PartialEq)] +pub struct Limits { + pub max_keys: u32, + /// Token bucket: sustained ops per second and burst capacity. + pub ops_per_sec: u32, + pub burst: u32, +} + +#[derive(Debug)] +struct Ns { + limits: Limits, + keys: u32, + tokens_milli: u64, + last_ms: u64, +} + +#[derive(Debug, PartialEq)] +pub enum Denied { + NoNamespace, + KeyQuota(String), + RateLimited(String), +} + +#[derive(Default)] +pub struct Registry { + ns: BTreeMap, +} + +fn valid(path: &str) -> bool { + !path.is_empty() && path.split('/').all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')) +} + +fn chain(path: &str) -> Vec { + let parts: Vec<&str> = path.split('/').collect(); + (1..=parts.len()).map(|i| parts[..i].join("/")).collect() +} + +impl Registry { + pub fn create(&mut self, path: &str, limits: Limits, now_ms: u64) -> Result<(), Error> { + if !valid(path) || self.ns.contains_key(path) || limits.burst == 0 { + return Err(Error::InvalidArgument); + } + if let Some((parent, _)) = path.rsplit_once('/') { + let p = self.ns.get(parent).ok_or(Error::InvalidArgument)?; + if limits.max_keys > p.limits.max_keys || limits.ops_per_sec > p.limits.ops_per_sec || limits.burst > p.limits.burst { + return Err(Error::InvalidArgument); + } + } + let tokens_milli = limits.burst as u64 * 1000; + self.ns.insert(path.into(), Ns { limits, keys: 0, tokens_milli, last_ms: now_ms }); + Ok(()) + } + + fn refill(n: &mut Ns, now: u64) { + let dt = now.saturating_sub(n.last_ms); + n.tokens_milli = (n.tokens_milli + dt * n.limits.ops_per_sec as u64).min(n.limits.burst as u64 * 1000); + n.last_ms = now.max(n.last_ms); + } + + /// Charge one crypto op to `path` and its ancestors. Either all are charged or none are. + pub fn charge_op(&mut self, path: &str, now_ms: u64) -> Result<(), Denied> { + let c = chain(path); + if c.iter().any(|p| !self.ns.contains_key(p)) { + return Err(Denied::NoNamespace); + } + for p in &c { + let n = self.ns.get_mut(p).unwrap(); + Self::refill(n, now_ms); + if n.tokens_milli < 1000 { + return Err(Denied::RateLimited(p.clone())); + } + } + for p in &c { + self.ns.get_mut(p).unwrap().tokens_milli -= 1000; + } + Ok(()) + } + + /// Reserve a key slot in `path` and its ancestors (each ancestor counts its whole subtree). + pub fn add_key(&mut self, path: &str) -> Result<(), Denied> { + let c = chain(path); + if c.iter().any(|p| !self.ns.contains_key(p)) { + return Err(Denied::NoNamespace); + } + if let Some(p) = c.iter().find(|p| { let n = &self.ns[*p]; n.keys >= n.limits.max_keys }) { + return Err(Denied::KeyQuota(p.clone())); + } + for p in &c { + self.ns.get_mut(p).unwrap().keys += 1; + } + Ok(()) + } + + pub fn remove_key(&mut self, path: &str) { + for p in chain(path) { + if let Some(n) = self.ns.get_mut(&p) { + n.keys = n.keys.saturating_sub(1); + } + } + } + + pub fn usage(&self, path: &str) -> Option<(u32, Limits)> { + self.ns.get(path).map(|n| (n.keys, n.limits.clone())) + } +} diff --git a/crates/encryptoglyph/tests/tenancy.rs b/crates/encryptoglyph/tests/tenancy.rs new file mode 100644 index 0000000..a047f27 --- /dev/null +++ b/crates/encryptoglyph/tests/tenancy.rs @@ -0,0 +1,34 @@ +use encryptoglyph::tenancy::{Denied, Limits, Registry}; + +fn l(k: u32, r: u32, b: u32) -> Limits { + Limits { max_keys: k, ops_per_sec: r, burst: b } +} + +#[test] +fn hierarchy_quotas_and_rates() { + let mut r = Registry::default(); + r.create("msp", l(3, 10, 4), 0).unwrap(); + r.create("msp/acme", l(2, 10, 2), 0).unwrap(); + r.create("msp/beta", l(2, 10, 4), 0).unwrap(); + assert!(r.create("msp/huge", l(9, 1, 1), 0).is_err()); + assert!(r.create("orphan/x", l(1, 1, 1), 0).is_err()); + assert!(r.create("bad path", l(1, 1, 1), 0).is_err()); + + r.add_key("msp/acme").unwrap(); + r.add_key("msp/acme").unwrap(); + assert_eq!(r.add_key("msp/acme"), Err(Denied::KeyQuota("msp/acme".into()))); + r.add_key("msp/beta").unwrap(); + assert_eq!(r.add_key("msp/beta"), Err(Denied::KeyQuota("msp".into()))); + r.remove_key("msp/acme"); + r.add_key("msp/beta").unwrap(); + assert_eq!(r.usage("msp").unwrap().0, 3); + + r.charge_op("msp/acme", 0).unwrap(); + r.charge_op("msp/acme", 0).unwrap(); + assert_eq!(r.charge_op("msp/acme", 0), Err(Denied::RateLimited("msp/acme".into()))); + r.charge_op("msp/beta", 0).unwrap(); + r.charge_op("msp/beta", 0).unwrap(); + assert_eq!(r.charge_op("msp/beta", 0), Err(Denied::RateLimited("msp".into()))); + r.charge_op("msp/acme", 100).unwrap(); // 100 ms at 10 ops/s refills one token + assert_eq!(r.charge_op("nope", 0), Err(Denied::NoNamespace)); +}