From 01331c1e4c78ebfcc47b3e7273e449274551c073 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 21 Jul 2026 10:26:31 +0200 Subject: [PATCH] Add admin config and audit log Bearer-token-gated admin endpoints to read/update the DB-backed operational config (fee_address, bet_amount_sats) without a redeploy, plus a lightweight audit log writer for round/payout/config events. Co-Authored-By: Claude Sonnet 5 --- app/api/routes/admin.py | 44 +++++++++++++++++++++++++++ app/audit/__init__.py | 0 app/audit/log.py | 22 ++++++++++++++ tests/unit/test_admin.py | 64 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 app/api/routes/admin.py create mode 100644 app/audit/__init__.py create mode 100644 app/audit/log.py create mode 100644 tests/unit/test_admin.py diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py new file mode 100644 index 0000000..c045cac --- /dev/null +++ b/app/api/routes/admin.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Depends, Header, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.db.session import get_session +from app.rounds.config import get_round_config + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +async def require_admin(x_admin_token: str = Header(default="")) -> None: + if not settings.admin_token or x_admin_token != settings.admin_token: + raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") + + +class RoundConfigResponse(BaseModel): + fee_address: str + bet_amount_sats: int + + +class RoundConfigUpdate(BaseModel): + fee_address: str | None = None + bet_amount_sats: int | None = None + + +@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) +async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: + config = await get_round_config(session) + await session.commit() + return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats) + + +@router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) +async def update_config( + body: RoundConfigUpdate, session: AsyncSession = Depends(get_session) +) -> RoundConfigResponse: + config = await get_round_config(session) + if body.fee_address is not None: + config.fee_address = body.fee_address + if body.bet_amount_sats is not None: + config.bet_amount_sats = body.bet_amount_sats + await session.commit() + return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats) diff --git a/app/audit/__init__.py b/app/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audit/log.py b/app/audit/log.py new file mode 100644 index 0000000..4510ab6 --- /dev/null +++ b/app/audit/log.py @@ -0,0 +1,22 @@ +import json + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import AuditLog + + +async def write_audit_log( + session: AsyncSession, + event_type: str, + payload: dict, + user_id: int | None = None, + round_id: int | None = None, +) -> None: + session.add( + AuditLog( + event_type=event_type, + payload_json=json.dumps(payload), + user_id=user_id, + round_id=round_id, + ) + ) diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py new file mode 100644 index 0000000..df9c633 --- /dev/null +++ b/tests/unit/test_admin.py @@ -0,0 +1,64 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import settings + + +@pytest.fixture +async def client(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db") + monkeypatch.setattr(settings, "admin_token", "test-admin-token") + monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret") + + from sqlalchemy.ext.asyncio import create_async_engine + + from app.db import base as db_base + + db_base.engine = create_async_engine(settings.database_url) + from sqlalchemy.ext.asyncio import async_sessionmaker + + db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False) + + async with db_base.engine.begin() as conn: + await conn.run_sync(db_base.Base.metadata.create_all) + + from app.api.routes.admin import router as admin_router + from fastapi import FastAPI + + app = FastAPI() + app.include_router(admin_router) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + await db_base.engine.dispose() + + +async def test_admin_requires_token(client): + resp = await client.get("/admin/config") + assert resp.status_code == 403 + + +async def test_admin_rejects_wrong_token(client): + resp = await client.get("/admin/config", headers={"X-Admin-Token": "wrong"}) + assert resp.status_code == 403 + + +async def test_admin_reads_and_updates_config(client): + headers = {"X-Admin-Token": "test-admin-token"} + + resp = await client.get("/admin/config", headers=headers) + assert resp.status_code == 200 + assert resp.json()["fee_address"] == "" + + resp = await client.put( + "/admin/config", headers=headers, json={"fee_address": "plm1qfeeaddress", "bet_amount_sats": 500_000_000} + ) + assert resp.status_code == 200 + body = resp.json() + assert body["fee_address"] == "plm1qfeeaddress" + assert body["bet_amount_sats"] == 500_000_000 + + resp = await client.get("/admin/config", headers=headers) + assert resp.json()["fee_address"] == "plm1qfeeaddress"