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())
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user