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,47 @@
|
||||
"""
|
||||
Per-request credential resolution for multi-tenant operation.
|
||||
|
||||
A tool calls :func:`resolve_caller_credentials` to get the ``(athlete_id, api_key)``
|
||||
for whoever made the request. Identity comes from the OAuth access token
|
||||
(``get_access_token().subject``); the credentials come from that user's enabled
|
||||
store record. If there is no auth context (stdio / local development) it falls back
|
||||
to the ``API_KEY`` / ``ATHLETE_ID`` environment configuration, so single-user local
|
||||
runs keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
|
||||
from intervals_mcp_server import store
|
||||
from intervals_mcp_server.config import get_config
|
||||
|
||||
_SETUP_HINT = (
|
||||
"Your Intervals.icu account isn't ready yet. Sign in to the Intervals.icu MCP "
|
||||
"portal, wait for an admin to approve your account, then add your athlete ID and "
|
||||
"API key there."
|
||||
)
|
||||
|
||||
|
||||
class CredentialError(Exception):
|
||||
"""Raised when the caller has no usable Intervals.icu credentials.
|
||||
|
||||
The message is safe to surface to the user.
|
||||
"""
|
||||
|
||||
|
||||
async def resolve_caller_credentials() -> tuple[str, str]:
|
||||
"""Return ``(athlete_id, api_key)`` for the current caller or raise CredentialError."""
|
||||
token = get_access_token()
|
||||
if token is not None and token.subject:
|
||||
creds = await store.get_active_credentials(token.subject)
|
||||
if creds is None:
|
||||
raise CredentialError(_SETUP_HINT)
|
||||
return creds
|
||||
|
||||
# No authenticated context (stdio / local dev): use env config if present.
|
||||
config = get_config()
|
||||
if config.api_key and config.athlete_id:
|
||||
return config.athlete_id, config.api_key
|
||||
|
||||
raise CredentialError("Not authenticated and no local credentials are configured.")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Symmetric encryption for user secrets (the per-user Intervals.icu API key).
|
||||
|
||||
Uses AES-256-GCM (authenticated encryption). The key is supplied as a
|
||||
base64-encoded 32-byte value via the ``INTERVALS_ENC_KEY`` environment variable
|
||||
(mounted from a Kubernetes secret) and is shared by the MCP server and the UI
|
||||
service so both can read/write the same ciphertext.
|
||||
|
||||
Ciphertext layout: ``nonce(12 bytes) || ciphertext+tag``. A fresh random nonce is
|
||||
used per encryption, so encrypting the same plaintext twice yields different bytes.
|
||||
The API key must be recoverable (the server uses it to call Intervals), so this is
|
||||
reversible encryption, not hashing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
_NONCE_LEN = 12
|
||||
_KEY_LEN = 32
|
||||
|
||||
|
||||
class CryptoError(Exception):
|
||||
"""Raised when encryption is misconfigured or a payload cannot be decrypted."""
|
||||
|
||||
|
||||
def load_key(raw: str | None = None) -> bytes:
|
||||
"""Return the 32-byte AES key from a base64 string (or ``INTERVALS_ENC_KEY``)."""
|
||||
raw = raw if raw is not None else os.environ.get("INTERVALS_ENC_KEY")
|
||||
if not raw:
|
||||
raise CryptoError("INTERVALS_ENC_KEY is not set")
|
||||
try:
|
||||
key = base64.b64decode(raw, validate=True)
|
||||
except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined]
|
||||
raise CryptoError("INTERVALS_ENC_KEY is not valid base64") from exc
|
||||
if len(key) != _KEY_LEN:
|
||||
raise CryptoError(f"INTERVALS_ENC_KEY must decode to {_KEY_LEN} bytes, got {len(key)}")
|
||||
return key
|
||||
|
||||
|
||||
def encrypt(plaintext: str, key: bytes | None = None) -> bytes:
|
||||
"""Encrypt a string; returns ``nonce || ciphertext``."""
|
||||
key = key if key is not None else load_key()
|
||||
nonce = os.urandom(_NONCE_LEN)
|
||||
ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return nonce + ciphertext
|
||||
|
||||
|
||||
def decrypt(blob: bytes, key: bytes | None = None) -> str:
|
||||
"""Decrypt bytes produced by :func:`encrypt`. Raises CryptoError on tamper/wrong key."""
|
||||
key = key if key is not None else load_key()
|
||||
if len(blob) <= _NONCE_LEN:
|
||||
raise CryptoError("ciphertext too short")
|
||||
nonce, ciphertext = blob[:_NONCE_LEN], blob[_NONCE_LEN:]
|
||||
try:
|
||||
return AESGCM(key).decrypt(nonce, ciphertext, None).decode("utf-8")
|
||||
except Exception as exc: # noqa: BLE001 - InvalidTag etc.
|
||||
raise CryptoError("could not decrypt payload") from exc
|
||||
|
||||
|
||||
def generate_key_b64() -> str:
|
||||
"""Generate a fresh base64-encoded 32-byte key (for provisioning the secret)."""
|
||||
return base64.b64encode(os.urandom(_KEY_LEN)).decode("ascii")
|
||||
@@ -0,0 +1 @@
|
||||
"""Database layer for multi-tenant user credential storage."""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
SQLAlchemy models for the multi-tenant user store.
|
||||
|
||||
One row per Authentik subject. Intervals.icu credentials are optional (a user
|
||||
exists after their first login but before they add credentials) and the API key
|
||||
is stored encrypted (``api_key_enc``), never in plaintext. ``enabled`` is the
|
||||
admin-approval gate — new users are created disabled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, LargeBinary, String, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models."""
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A registered user and their (optional, encrypted) Intervals.icu credentials."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
sub: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
athlete_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
api_key_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@property
|
||||
def has_credentials(self) -> bool:
|
||||
"""True when the user has both an athlete id and an encrypted API key."""
|
||||
return bool(self.athlete_id and self.api_key_enc)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Async engine / session management.
|
||||
|
||||
The connection string comes from ``DATABASE_URL`` (e.g.
|
||||
``postgresql+asyncpg://user:pass@host/db``). The engine and sessionmaker are
|
||||
created lazily so importing this module never requires a database — tests and
|
||||
stdio/local runs that don't touch the store won't connect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
|
||||
_engine: AsyncEngine | None = None
|
||||
_sessionmaker: async_sessionmaker | None = None
|
||||
|
||||
|
||||
def configure(url: str, **engine_kwargs) -> None:
|
||||
"""Explicitly configure the engine (used by tests to point at aiosqlite)."""
|
||||
global _engine, _sessionmaker # noqa: PLW0603 - module-level singletons
|
||||
_engine = create_async_engine(url, **engine_kwargs)
|
||||
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_sessionmaker() -> async_sessionmaker:
|
||||
"""Return the process-wide async sessionmaker, creating it from DATABASE_URL if needed."""
|
||||
global _engine, _sessionmaker # noqa: PLW0603
|
||||
if _sessionmaker is None:
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if not url:
|
||||
raise RuntimeError("DATABASE_URL is not set")
|
||||
configure(url, pool_pre_ping=True)
|
||||
assert _sessionmaker is not None
|
||||
return _sessionmaker
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Drop the cached engine/sessionmaker (test isolation)."""
|
||||
global _engine, _sessionmaker # noqa: PLW0603
|
||||
_engine = None
|
||||
_sessionmaker = None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Async data-access for the user store.
|
||||
|
||||
Read/write helpers over the :class:`User` model. Credential setters encrypt the
|
||||
API key before it touches the database; the read path used by the MCP server
|
||||
(:func:`get_active_credentials`) only returns credentials for an *enabled* user
|
||||
that actually has them, and decrypts on the way out. Plaintext API keys never
|
||||
persist and are never returned to callers other than the request that will use
|
||||
them against Intervals.icu.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from intervals_mcp_server import crypto
|
||||
from intervals_mcp_server.db.models import User
|
||||
from intervals_mcp_server.db.session import get_sessionmaker
|
||||
|
||||
|
||||
async def get_user(session: AsyncSession, sub: str) -> User | None:
|
||||
"""Fetch a single user by subject."""
|
||||
return await session.get(User, sub)
|
||||
|
||||
|
||||
async def list_users(session: AsyncSession) -> list[User]:
|
||||
"""Return all users (admin listing), newest first."""
|
||||
result = await session.execute(select(User).order_by(User.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def upsert_login(session: AsyncSession, sub: str, email: str, name: str | None) -> User:
|
||||
"""Record a login: create the user (disabled) on first sight, else refresh profile.
|
||||
|
||||
Never flips ``enabled`` — approval is an explicit admin action.
|
||||
"""
|
||||
user = await session.get(User, sub)
|
||||
now = datetime.now(timezone.utc)
|
||||
if user is None:
|
||||
user = User(sub=sub, email=email, name=name, enabled=False, last_login_at=now)
|
||||
session.add(user)
|
||||
else:
|
||||
user.email = email
|
||||
user.name = name
|
||||
user.last_login_at = now
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def set_credentials(session: AsyncSession, sub: str, athlete_id: str, api_key: str) -> bool:
|
||||
"""Store (encrypted) Intervals.icu credentials for an existing user."""
|
||||
user = await session.get(User, sub)
|
||||
if user is None:
|
||||
return False
|
||||
user.athlete_id = athlete_id
|
||||
user.api_key_enc = crypto.encrypt(api_key)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def clear_credentials(session: AsyncSession, sub: str) -> bool:
|
||||
"""Remove stored credentials for a user."""
|
||||
user = await session.get(User, sub)
|
||||
if user is None:
|
||||
return False
|
||||
user.athlete_id = None
|
||||
user.api_key_enc = None
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def set_enabled(session: AsyncSession, sub: str, enabled: bool) -> bool:
|
||||
"""Enable/disable a user (admin action)."""
|
||||
user = await session.get(User, sub)
|
||||
if user is None:
|
||||
return False
|
||||
user.enabled = enabled
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def delete_user(session: AsyncSession, sub: str) -> bool:
|
||||
"""Delete a user and their stored credentials (admin action)."""
|
||||
user = await session.get(User, sub)
|
||||
if user is None:
|
||||
return False
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_active_credentials(sub: str) -> tuple[str, str] | None:
|
||||
"""Return ``(athlete_id, api_key)`` for an enabled user with credentials, else ``None``.
|
||||
|
||||
Opens and closes its own session; used by the MCP per-request resolver.
|
||||
"""
|
||||
sessionmaker = get_sessionmaker()
|
||||
async with sessionmaker() as session:
|
||||
user = await session.get(User, sub)
|
||||
if user is None or not user.enabled or not user.has_credentials:
|
||||
return None
|
||||
assert user.athlete_id is not None and user.api_key_enc is not None
|
||||
return user.athlete_id, crypto.decrypt(user.api_key_enc)
|
||||
Reference in New Issue
Block a user