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,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