diff --git a/src/opencmo/llm.py b/src/opencmo/llm.py index fb7496f..30e2d56 100644 --- a/src/opencmo/llm.py +++ b/src/opencmo/llm.py @@ -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) @@ -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 @@ -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) @@ -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 diff --git a/tests/test_settings_multitenant.py b/tests/test_settings_multitenant.py index e0dd553..e997cc7 100644 --- a/tests/test_settings_multitenant.py +++ b/tests/test_settings_multitenant.py @@ -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 # ---------------------------------------------------------------------------