feat(m7.2-A): introduce per-tenant DatabasePool #86

Closed
sharang wants to merge 2 commits from feat/m7.2a-tenant-db-pool into main
2 changed files with 71 additions and 9 deletions
Showing only changes of commit 003835764e - Show all commits
+35 -9
View File
@@ -15,6 +15,16 @@ use crate::error::AgentError;
/// on Linux, 63 on Windows; we target the conservative limit). /// on Linux, 63 on Windows; we target the conservative limit).
const MAX_DB_NAME_LEN: usize = 63; const MAX_DB_NAME_LEN: usize = 63;
/// Hex length of the SHA-256 truncation used for the hash fallback
/// tenant DB name (16 bytes → 32 hex chars). 16 bytes gives ~2^64
/// birthday-collision resistance — at our 10s-100s tenant scale this
/// is effectively impossible to hit.
const HASH_HEX_LEN: usize = 32;
/// Largest `db_prefix` that still guarantees the hash-fallback name
/// fits in the 63-byte cap: `prefix + "_" + 32 hex chars`.
const MAX_PREFIX_LEN: usize = MAX_DB_NAME_LEN - 1 - HASH_HEX_LEN;
/// Per-tenant Mongo connection broker (M7.2 isolation model). /// Per-tenant Mongo connection broker (M7.2 isolation model).
/// ///
/// Holds one [`Client`] and hands out [`Database`] handles physically /// Holds one [`Client`] and hands out [`Database`] handles physically
@@ -36,7 +46,19 @@ pub struct DatabasePool {
impl DatabasePool { impl DatabasePool {
/// Connect to the cluster and prepare to hand out tenant databases /// Connect to the cluster and prepare to hand out tenant databases
/// named `<db_prefix>_<tenant_id>`. /// named `<db_prefix>_<tenant_id>`.
///
/// Validates `db_prefix.len() <= MAX_PREFIX_LEN` so the
/// hash-fallback path is provably within Mongo's 63-byte db-name
/// cap. Refuses to construct a pool that could ever produce an
/// over-long name.
pub async fn connect(uri: &str, db_prefix: &str) -> Result<Self, AgentError> { pub async fn connect(uri: &str, db_prefix: &str) -> Result<Self, AgentError> {
if db_prefix.len() > MAX_PREFIX_LEN {
return Err(AgentError::Other(format!(
"db_prefix '{db_prefix}' is {} chars; max is {MAX_PREFIX_LEN} so the \
hash-fallback tenant DB name fits Mongo's {MAX_DB_NAME_LEN}-byte cap",
db_prefix.len()
)));
}
let client = Client::with_uri_str(uri).await?; let client = Client::with_uri_str(uri).await?;
client client
.database("admin") .database("admin")
@@ -79,10 +101,17 @@ impl DatabasePool {
/// Compute the Mongo database name for a tenant. Public for tests /// Compute the Mongo database name for a tenant. Public for tests
/// and tenant offboarding (`pool.client().database(name).drop()`). /// and tenant offboarding (`pool.client().database(name).drop()`).
/// ///
/// Format: `<prefix>_<sanitized_tenant_id>` if it fits in 63 chars, /// Format: `<prefix>_<sanitized_tenant_id>` if it fits the 63-byte
/// otherwise `<prefix>_<sha256-16hex-of-tenant_id>`. The hash /// cap, else `<prefix>_<sha256-16-byte-hex-of-tenant_id>`. The
/// fallback is collision-resistant in practice (2^64 keyspace) /// `db_prefix` length invariant established at [`Self::connect`]
/// while keeping the name bounded. /// guarantees the hash-fallback name always fits — no runtime
/// assertion needed.
///
/// Collision resistance: the hash fallback is a 16-byte SHA-256
/// truncation, which gives ~2^64 birthday-collision resistance. At
/// our 10s100s tenant scale the probability of two tenant_ids
/// colliding is effectively zero. (8-byte truncation would have
/// been ~2^32 — too close for comfort on a regulated product.)
pub fn tenant_db_name(&self, tenant_id: &str) -> String { pub fn tenant_db_name(&self, tenant_id: &str) -> String {
let sanitized = sanitize_tenant_id(tenant_id); let sanitized = sanitize_tenant_id(tenant_id);
let natural = format!("{}_{}", self.db_prefix, sanitized); let natural = format!("{}_{}", self.db_prefix, sanitized);
@@ -92,11 +121,8 @@ impl DatabasePool {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(tenant_id.as_bytes()); hasher.update(tenant_id.as_bytes());
let digest = hasher.finalize(); let digest = hasher.finalize();
// 16 hex chars = 8 bytes = 64-bit truncation. let suffix = hex::encode(&digest[..HASH_HEX_LEN / 2]);
let suffix = hex::encode(&digest[..8]); format!("{}_{}", self.db_prefix, suffix)
let hashed = format!("{}_{}", self.db_prefix, suffix);
debug_assert!(hashed.len() <= MAX_DB_NAME_LEN);
hashed
} }
} }
@@ -172,9 +172,45 @@ async fn tenant_db_name_falls_back_to_hash_when_too_long() {
let name = pool.tenant_db_name(&huge); let name = pool.tenant_db_name(&huge);
assert!(name.len() <= 63, "hashed name should fit: {name}"); assert!(name.len() <= 63, "hashed name should fit: {name}");
assert!(name.starts_with("m72a_long_")); assert!(name.starts_with("m72a_long_"));
// The hash suffix is 32 hex chars (16-byte SHA-256 truncation).
let suffix = name.trim_start_matches("m72a_long_");
assert_eq!(
suffix.len(),
32,
"expected 32-hex suffix (16-byte hash), got {suffix:?}"
);
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
// Stable: same input → same output. // Stable: same input → same output.
assert_eq!(name, pool.tenant_db_name(&huge)); assert_eq!(name, pool.tenant_db_name(&huge));
// Different inputs → different outputs (collision check on a tiny
// sample — full birthday-resistance is a proof not a test).
let huge2 = "y".repeat(100);
assert_ne!(pool.tenant_db_name(&huge), pool.tenant_db_name(&huge2));
}
#[tokio::test]
async fn connect_rejects_overlong_db_prefix() {
let uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
// MAX_PREFIX_LEN is 30 (= 63 - 1 - 32). A 31-char prefix MUST be
// rejected at construction so the hash-fallback path can never
// produce an over-long db name at runtime.
let too_long = "a".repeat(31);
let err = DatabasePool::connect(&uri, &too_long).await.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("max is 30") || msg.contains(&too_long),
"error should explain the cap: {msg}"
);
// Exactly 30 chars is the inclusive bound — must succeed.
let just_right = "a".repeat(30);
let _ = DatabasePool::connect(&uri, &just_right)
.await
.expect("30-char prefix should be accepted");
} }
/// Short UUID slug for keeping test prefixes well under Mongo's 63-byte /// Short UUID slug for keeping test prefixes well under Mongo's 63-byte