feat(multi-tenant): data layer, encryption, and per-request credential resolver
build-image / test (push) Successful in 19s
build-image / build (push) Successful in 19s

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:
2026-07-04 19:07:10 -04:00
parent 7c36850b72
commit 31eb45c3f8
12 changed files with 836 additions and 1 deletions
+62
View File
@@ -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())
+74
View File
@@ -0,0 +1,74 @@
"""
Tests for intervals_mcp_server.crypto (AES-256-GCM for the user API key).
Covers round-trip, per-message nonce randomness, tamper/wrong-key detection,
and key loading/validation from the environment.
"""
import base64
import pytest
from intervals_mcp_server import crypto
from intervals_mcp_server.crypto import CryptoError
KEY = crypto.load_key(crypto.generate_key_b64())
def test_roundtrip():
assert crypto.decrypt(crypto.encrypt("s3cr3t-api-key", KEY), KEY) == "s3cr3t-api-key"
def test_same_plaintext_encrypts_differently():
a = crypto.encrypt("same", KEY)
b = crypto.encrypt("same", KEY)
assert a != b # random nonce per message
assert crypto.decrypt(a, KEY) == crypto.decrypt(b, KEY) == "same"
def test_ciphertext_does_not_contain_plaintext():
assert b"api-key" not in crypto.encrypt("my-api-key", KEY)
def test_wrong_key_rejected():
blob = crypto.encrypt("x", KEY)
with pytest.raises(CryptoError):
crypto.decrypt(blob, crypto.load_key(crypto.generate_key_b64()))
def test_tampered_ciphertext_rejected():
blob = bytearray(crypto.encrypt("x", KEY))
blob[-1] ^= 0x01 # flip a bit in the GCM tag
with pytest.raises(CryptoError):
crypto.decrypt(bytes(blob), KEY)
def test_short_ciphertext_rejected():
with pytest.raises(CryptoError):
crypto.decrypt(b"tiny", KEY)
def test_load_key_from_env(monkeypatch):
monkeypatch.setenv("INTERVALS_ENC_KEY", crypto.generate_key_b64())
assert len(crypto.load_key()) == 32
def test_load_key_missing(monkeypatch):
monkeypatch.delenv("INTERVALS_ENC_KEY", raising=False)
with pytest.raises(CryptoError):
crypto.load_key()
def test_load_key_wrong_length():
with pytest.raises(CryptoError):
crypto.load_key(base64.b64encode(b"too-short").decode())
def test_load_key_bad_base64():
with pytest.raises(CryptoError):
crypto.load_key("!!!not-base64!!!")
def test_encrypt_defaults_to_env_key(monkeypatch):
monkeypatch.setenv("INTERVALS_ENC_KEY", crypto.generate_key_b64())
assert crypto.decrypt(crypto.encrypt("hello")) == "hello"
+126
View File
@@ -0,0 +1,126 @@
"""
Tests for intervals_mcp_server.store against an in-memory SQLite database.
Verifies the real behaviors that matter: new users are created disabled,
credentials are stored encrypted (never plaintext), and get_active_credentials
only returns decrypted creds for an enabled user that actually has them.
"""
import asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from intervals_mcp_server import crypto, store
from intervals_mcp_server.db.models import Base
KEY = crypto.generate_key_b64()
def _run_db(monkeypatch, body):
"""Run `body(sessionmaker)` against a fresh in-memory DB, all in one event loop."""
async def go():
monkeypatch.setenv("INTERVALS_ENC_KEY", KEY)
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(store, "get_sessionmaker", lambda: maker)
try:
await body(maker)
finally:
await engine.dispose()
asyncio.run(go())
def test_first_login_creates_disabled_user(monkeypatch):
async def body(maker):
async with maker() as s:
user = await store.upsert_login(s, "sub1", "a@b.com", "Alice")
assert user.enabled is False
assert user.has_credentials is False
assert user.last_login_at is not None
_run_db(monkeypatch, body)
def test_repeat_login_refreshes_profile_but_not_enabled(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", "Alice")
await store.set_enabled(s, "sub1", True)
# a later login must not touch the approval flag
user = await store.upsert_login(s, "sub1", "new@b.com", "Al")
assert user.email == "new@b.com"
assert user.enabled is True # unchanged by login
assert len(await store.list_users(s)) == 1
_run_db(monkeypatch, body)
def test_set_credentials_stores_encrypted(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", None)
assert await store.set_credentials(s, "sub1", "i123", "SECRET-KEY") is True
user = await store.get_user(s, "sub1")
assert user.athlete_id == "i123"
assert user.api_key_enc is not None
assert b"SECRET-KEY" not in user.api_key_enc # not plaintext
assert crypto.decrypt(user.api_key_enc) == "SECRET-KEY"
_run_db(monkeypatch, body)
def test_set_credentials_unknown_user(monkeypatch):
async def body(maker):
async with maker() as s:
assert await store.set_credentials(s, "ghost", "i1", "k") is False
_run_db(monkeypatch, body)
def test_active_credentials_gated_on_enabled_and_present(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", None)
await store.set_credentials(s, "sub1", "i123", "KEY")
# has creds but not enabled -> None
assert await store.get_active_credentials("sub1") is None
async with maker() as s:
await store.set_enabled(s, "sub1", True)
# enabled + creds -> decrypted tuple
assert await store.get_active_credentials("sub1") == ("i123", "KEY")
_run_db(monkeypatch, body)
def test_active_credentials_enabled_without_creds(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", None)
await store.set_enabled(s, "sub1", True)
assert await store.get_active_credentials("sub1") is None
_run_db(monkeypatch, body)
def test_clear_and_delete(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", None)
await store.set_credentials(s, "sub1", "i1", "k")
assert await store.clear_credentials(s, "sub1") is True
user = await store.get_user(s, "sub1")
assert user.athlete_id is None and user.api_key_enc is None
assert await store.delete_user(s, "sub1") is True
assert await store.get_user(s, "sub1") is None
assert await store.delete_user(s, "ghost") is False
_run_db(monkeypatch, body)