fix(dashboard): attach Keycloak token on agent API calls #90
@@ -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 10s–100s 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
|
||||||
|
|||||||
Reference in New Issue
Block a user