feat(multi-tenant): data layer, encryption, and per-request credential resolver
Foundation for multi-user operation (Phase 1): - crypto.py: AES-256-GCM encrypt/decrypt for the per-user Intervals API key, key from INTERVALS_ENC_KEY (base64 32 bytes). Random nonce per message. - db/models.py + db/session.py: SQLAlchemy 2.0 async User model (keyed on the Authentik sub; api_key stored encrypted; enabled = admin-approval gate) and a lazy async engine/sessionmaker from DATABASE_URL. - store.py: async CRUD. New users created disabled; login never flips enabled; get_active_credentials returns decrypted creds only for an enabled user that has them. - credentials.py: resolve_caller_credentials() maps get_access_token().subject to that user's stored creds, falling back to env config only when unauthenticated (stdio/local). - Tests (SQLite in-memory, no infra): crypto round-trip/tamper, store gating, resolver paths. Suite 199 passing at 90.4% (gate holds). Deps: sqlalchemy[asyncio], asyncpg, alembic (+ aiosqlite for tests). Pin py3.12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Tests for the per-request credential resolver.
|
||||
|
||||
Identity comes from the OAuth token; credentials from the enabled store record;
|
||||
with a fallback to env config only when there is no auth context (local/stdio).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from intervals_mcp_server import credentials
|
||||
from intervals_mcp_server.config import Config
|
||||
from intervals_mcp_server.credentials import CredentialError, resolve_caller_credentials
|
||||
|
||||
|
||||
def _token(sub):
|
||||
return SimpleNamespace(subject=sub)
|
||||
|
||||
|
||||
def test_uses_authenticated_users_stored_creds(monkeypatch):
|
||||
monkeypatch.setattr(credentials, "get_access_token", lambda: _token("sub1"))
|
||||
|
||||
async def _creds(sub):
|
||||
assert sub == "sub1"
|
||||
return ("i123", "user-key")
|
||||
|
||||
monkeypatch.setattr(credentials.store, "get_active_credentials", _creds)
|
||||
assert asyncio.run(resolve_caller_credentials()) == ("i123", "user-key")
|
||||
|
||||
|
||||
def test_authenticated_but_not_set_up_raises(monkeypatch):
|
||||
monkeypatch.setattr(credentials, "get_access_token", lambda: _token("sub1"))
|
||||
|
||||
async def _none(_sub):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(credentials.store, "get_active_credentials", _none)
|
||||
with pytest.raises(CredentialError, match="isn't ready yet"):
|
||||
asyncio.run(resolve_caller_credentials())
|
||||
|
||||
|
||||
def test_no_token_falls_back_to_env_config(monkeypatch):
|
||||
monkeypatch.setattr(credentials, "get_access_token", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
credentials,
|
||||
"get_config",
|
||||
lambda: Config(api_key="envkey", athlete_id="i999", intervals_api_base_url="x", user_agent="t"),
|
||||
)
|
||||
assert asyncio.run(resolve_caller_credentials()) == ("i999", "envkey")
|
||||
|
||||
|
||||
def test_no_token_no_env_raises(monkeypatch):
|
||||
monkeypatch.setattr(credentials, "get_access_token", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
credentials,
|
||||
"get_config",
|
||||
lambda: Config(api_key="", athlete_id="", intervals_api_base_url="x", user_agent="t"),
|
||||
)
|
||||
with pytest.raises(CredentialError, match="Not authenticated"):
|
||||
asyncio.run(resolve_caller_credentials())
|
||||
Reference in New Issue
Block a user