Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/opencmo/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@
"OPENAI_BASE_URL",
"OPENCMO_MODEL_DEFAULT",
})
_ACCOUNT_SCOPED_SECRET_KEYS = frozenset({
"REDDIT_CLIENT_ID",
"REDDIT_CLIENT_SECRET",
"REDDIT_USERNAME",
"REDDIT_PASSWORD",
"TWITTER_API_KEY",
"TWITTER_API_SECRET",
"TWITTER_ACCESS_TOKEN",
"TWITTER_ACCESS_SECRET",
})

# ---------------------------------------------------------------------------
# ContextVar — per-request key isolation (asyncio Task-local)
Expand Down Expand Up @@ -178,7 +188,12 @@ def get_key(name: str, default: str | None = None) -> str | None:
if val:
return val

# 3. os.environ
# 3. Sensitive account-scoped secrets must fail closed to avoid
# cross-account credential bleed through process-global env.
if name in _ACCOUNT_SCOPED_SECRET_KEYS:
return default

# 4. os.environ
val = os.environ.get(name)
if val:
return val
Expand Down Expand Up @@ -222,13 +237,17 @@ async def get_key_async(name: str, default: str | None = None) -> str | None:
except Exception:
pass

# 4. For core router defaults, prefer env/.env over persisted DB settings.
# 4. Sensitive account-scoped secrets must not fall through to system/env.
if name in _ACCOUNT_SCOPED_SECRET_KEYS:
return default

# 5. For core router defaults, prefer env/.env over persisted DB settings.
if name in _ENV_PRIORITY_KEYS:
val = os.environ.get(name)
if val:
return val

# 5. System fallback (admin account → legacy settings table)
# 6. System fallback (admin account → legacy settings table)
try:
from opencmo import storage
val = await storage.get_system_setting(name)
Expand All @@ -237,7 +256,7 @@ async def get_key_async(name: str, default: str | None = None) -> str | None:
except Exception:
pass # DB may not be initialized yet

# 6. os.environ
# 7. os.environ
val = os.environ.get(name)
if val:
return val
Expand Down
17 changes: 17 additions & 0 deletions tests/test_settings_multitenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,23 @@ def test_get_key_async_reads_per_account_via_db_when_snapshot_empty(isolated_db)
assert value == "alice_db_only"


def test_publish_credentials_do_not_fallback_to_env_or_system(isolated_db, monkeypatch):
"""Tenant-missing publish creds must not resolve from global/system fallbacks."""
admin_id = asyncio.run(storage.get_admin_account_id())
_, tenant_account = _seed_account(email="tenant@example.test", name="Tenant")
asyncio.run(storage.set_account_setting(admin_id, "REDDIT_CLIENT_ID", "admin-cid"))
monkeypatch.setenv("REDDIT_CLIENT_ID", "env-cid")

acct_token = llm.set_current_account_id(tenant_account)
snap_token = llm.set_current_account_settings({})
try:
assert asyncio.run(llm.get_key_async("REDDIT_CLIENT_ID")) is None
assert llm.get_key("REDDIT_CLIENT_ID") is None
finally:
llm.reset_current_account_settings(snap_token)
llm.reset_current_account_id(acct_token)


# ---------------------------------------------------------------------------
# System fallback / legacy table
# ---------------------------------------------------------------------------
Expand Down
Loading