fix(tenant): guard against sanitization collisions with production tenant
The request-level tenant guard compared the raw user string exactly
(user == PRODUCTION_TENANT), but all local namespaces (Qdrant
collections, Redis keys) are derived through sanitize_user_id(), which
lowercases and strips/maps punctuation. Case or punctuation variants
("JPMSchweitzer", "jpmschweitzer.", " jpmschweitzer") therefore passed
the guard yet resolved to the production namespaces, letting a dev
instance on the shared services read/write production tenant data.
- context.py: compare sanitize_user_id(user) against the sanitized
production tenant; expose the guard as public apply_tenant_guard()
- config.py: startup refusal validator uses the same sanitized
comparison, so a colliding DEFAULT_USER refuses startup loudly
instead of relying on the allowlist fallback
- tests: variant matrix at both config and request-context level,
plus a non-colliding passthrough case
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+10
-1
@@ -224,10 +224,19 @@ class Config(BaseSettings):
|
||||
This is the hard stop of the tenant isolation guard: a dev/test
|
||||
instance must never be able to read or write the production
|
||||
tenant's data in the shared services.
|
||||
|
||||
The comparison is on the sanitized form: namespaces are derived
|
||||
through sanitize_user_id(), so variants like "JPMSchweitzer" or
|
||||
"jpmschweitzer." collide with the production namespaces and are
|
||||
refused just as loudly.
|
||||
"""
|
||||
from src.core.multi_tenancy import sanitize_user_id
|
||||
|
||||
if (
|
||||
self.ENVIRONMENT != Environment.PRODUCTION
|
||||
and self.DEFAULT_USER == PRODUCTION_TENANT
|
||||
and self.DEFAULT_USER is not None
|
||||
and sanitize_user_id(self.DEFAULT_USER)
|
||||
== sanitize_user_id(PRODUCTION_TENANT)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Refusing to start: ENVIRONMENT={self.ENVIRONMENT.value} is "
|
||||
|
||||
+11
-3
@@ -41,20 +41,28 @@ current_conversation: ContextVar[str | None] = ContextVar(
|
||||
)
|
||||
|
||||
|
||||
def _apply_tenant_guard(user: str) -> str:
|
||||
def apply_tenant_guard(user: str) -> str:
|
||||
"""
|
||||
Enforce tenant isolation at request-context resolution.
|
||||
|
||||
In non-production environments the production tenant must never be
|
||||
the effective user - a request that explicitly asks for it is forced
|
||||
to the reserved test tenant instead (with a loud log line).
|
||||
|
||||
Comparison happens on the *sanitized* form of the user: every local
|
||||
namespace (Qdrant collection, Redis key) is derived through
|
||||
sanitize_user_id(), so any raw variant that collides with the
|
||||
production tenant after sanitization ("JPMSchweitzer",
|
||||
"jpmschweitzer.", " jpmschweitzer", ...) would otherwise resolve to
|
||||
the production namespaces. Those variants are forced too.
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from src.core.config import PRODUCTION_TENANT, TEST_TENANT, Environment, config
|
||||
from src.core.multi_tenancy import sanitize_user_id
|
||||
|
||||
if (
|
||||
config.ENVIRONMENT != Environment.PRODUCTION
|
||||
and user == PRODUCTION_TENANT
|
||||
and sanitize_user_id(user) == sanitize_user_id(PRODUCTION_TENANT)
|
||||
):
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
@@ -84,7 +92,7 @@ def get_user() -> str:
|
||||
user = current_user.get()
|
||||
if user == _USER_NOT_SET:
|
||||
return get_default_user()
|
||||
return _apply_tenant_guard(user)
|
||||
return apply_tenant_guard(user)
|
||||
|
||||
|
||||
def get_conversation_id() -> str | None:
|
||||
|
||||
Reference in New Issue
Block a user