Files
Chris Farhood 31eb45c3f8
build-image / test (push) Successful in 19s
build-image / build (push) Successful in 19s
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>
2026-07-04 19:07:10 -04:00

107 lines
3.6 KiB
Python

"""
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)