Files
plm-lottery/app/api/routes/admin.py
T
davideandClaude Opus 5 85dce221c5 Validate admin config and registration input, and log config changes
fee_address was the dangerous one (B-05). PUT /admin/config assigned whatever it
was given, and a well-formed address from another chain (bc1...) parses fine as a
witness program — so every round's 30% commission would be signed and broadcast
to a script nobody holds the key for. A malformed one instead wedged the payout
with an unhandled EmbitError. It now has to pass is_valid_plm_address, the same
check user withdrawals already had. Numeric fields got bounds too:
fee_rate_sat_vb=0 produces transactions no node relays, which stalls bets,
payouts and withdrawals alike, and round_duration_seconds=0 expires a round the
instant it opens.

Config changes are audit-logged (B-10). /pause and /resume were logged but a
config edit wasn't, so the most sensitive setting in the system could be changed
without leaving any trace — contradicting CLAUDE.md, which says audit_log records
what changed. The entry carries a before/after diff per field, computed before
assignment, and no-op updates write nothing. `paused` was removed from
_CONFIG_FIELDS so the maintenance switch has exactly one audited path; it stays
in the response model.

Admin token comparison is constant-time (B-14), with the empty-token check kept
*ahead* of it: compare_digest("", "") returns True, so the obvious ordering would
have opened the panel on any instance without an ADMIN_TOKEN.

Registration input (B-12). It accepted an empty username and a one-character
password while /users/me/change-password demanded 8 — an odd place to be lenient
on a custodial system holding real funds. MIN_PASSWORD_LENGTH moved to
auth/security.py so both share it, and the username is constrained to 3-32 chars
of [A-Za-z0-9_.-]. The IntegrityError handler also distinguishes a username
collision (answers username_taken) from a derivation-index one (retries): a
concurrent duplicate username used to be retried five times and then reported as
derivation_index_conflict, which told the user the wrong thing.

verify_password (B-13) catches VerificationError and InvalidHashError, not just
VerifyMismatchError, so an unparseable stored hash reads as "wrong password"
instead of a 500 — logged as an error, since that one is a data problem.

guida-admin.md gains a table of the audit events worth watching, including
payout_failed, which needs manual intervention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:32:48 +02:00

335 lines
12 KiB
Python

import json
import secrets
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.auth.security import hash_password
from app.config import settings
from app.db.models import AuditLog, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.wallet.address import is_valid_plm_address
from app.wallet.hd import derive_user_wif
router = APIRouter(prefix="/admin", tags=["admin"])
async def require_admin(x_admin_token: str = Header(default="")) -> None:
# An unset ADMIN_TOKEN denies everything — checked first, since compare_digest
# on two empty strings returns True and would otherwise open the panel to
# anyone on an instance that never configured a token.
if not settings.admin_token:
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
if not secrets.compare_digest(x_admin_token, settings.admin_token):
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
# `paused` is deliberately NOT here: it has its own audit-logged endpoints
# (/admin/pause, /admin/resume), and accepting it on PUT /config as well gave the
# operator an unlogged way to stop the lottery (B-10). It stays in the response
# model, so the dashboard still reads its current value from here.
_CONFIG_FIELDS = (
"fee_address",
"bet_amount_sats",
"round_duration_seconds",
"round_cooldown_seconds",
"fee_rate_sat_vb",
"rbf_timeout_seconds",
"draw_animation_seconds",
)
class RoundConfigResponse(BaseModel):
fee_address: str
bet_amount_sats: int
round_duration_seconds: int
round_cooldown_seconds: int
fee_rate_sat_vb: int
rbf_timeout_seconds: int
draw_animation_seconds: int
paused: bool
class RoundConfigUpdate(BaseModel):
"""Bounds are enforced here rather than trusting the operator: a value like
fee_rate_sat_vb=0 produces transactions no node will relay (stalling every bet,
payout and withdrawal), and round_duration_seconds=0 expires a round the instant
it opens. `paused` is not accepted — see _CONFIG_FIELDS."""
# An unvalidated fee_address was the worst of the lot: a malformed one wedged the
# payout with an unhandled EmbitError, and a well-formed *foreign* one (bc1...)
# parses fine as a witness program, so every round's 30 % commission would be
# broadcast to a script nobody holds the key for (B-05).
fee_address: str | None = None
bet_amount_sats: int | None = Field(default=None, gt=0, le=100_000 * 100_000_000)
round_duration_seconds: int | None = Field(default=None, ge=30, le=7 * 24 * 3600)
round_cooldown_seconds: int | None = Field(default=None, ge=0, le=24 * 3600)
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=10_000)
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
@field_validator("fee_address")
@classmethod
def _validate_fee_address(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not is_valid_plm_address(value):
raise ValueError(
"fee_address must be a valid PLM bech32 address (plm1...) — an address from "
"another chain would send every round's commission somewhere unspendable"
)
return value
def _config_response(config) -> RoundConfigResponse:
fields = {field: getattr(config, field) for field in _CONFIG_FIELDS}
fields["paused"] = config.paused
return RoundConfigResponse(**fields)
@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 _config_response(config)
@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)
# Diff computed before assignment so the audit entry records both sides. Without
# it, the most sensitive setting in the system (fee_address — where 30 % of every
# pool goes) could be changed without leaving any trace at all (B-10).
changes: dict[str, dict] = {}
for field in _CONFIG_FIELDS:
value = getattr(body, field)
if value is None:
continue
previous = getattr(config, field)
if previous == value:
continue
changes[field] = {"from": previous, "to": value}
setattr(config, field, value)
if changes:
await write_audit_log(session, "config_updated", changes)
await session.commit()
return _config_response(config)
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
"""Maintenance switch: the round in progress (if any) still closes, draws,
and pays out its winner normally — only opening the *next* round is
suppressed until /admin/resume is called (rounds/service.py)."""
config = await get_round_config(session)
config.paused = True
await write_audit_log(session, "lottery_paused", {})
await session.commit()
return _config_response(config)
@router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
config = await get_round_config(session)
config.paused = False
await write_audit_log(session, "lottery_resumed", {})
await session.commit()
return _config_response(config)
class AdminUserResponse(BaseModel):
id: int
username: str
address: str
balance_sats: int
created_at: str
@router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)])
async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]:
users = (await session.scalars(select(User).order_by(User.id))).all()
return [
AdminUserResponse(
id=u.id,
username=u.username,
address=u.address,
balance_sats=u.cached_balance_sats,
created_at=u.created_at.isoformat(),
)
for u in users
]
class AdminPrivkeyResponse(BaseModel):
address: str
wif: str
@router.get(
"/users/{user_id}/privkey", response_model=AdminPrivkeyResponse, dependencies=[Depends(require_admin)]
)
async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session)) -> AdminPrivkeyResponse:
"""Exports a user's raw private key for manual intervention (e.g. sweeping
funds back if something's stuck). Every access is audit-logged since this is
the most sensitive data the platform holds."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
wif = derive_user_wif(user.derivation_index)
await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id)
await session.commit()
return AdminPrivkeyResponse(address=user.address, wif=wif)
class AdminPasswordResetResponse(BaseModel):
username: str
new_password: str
@router.post(
"/users/{user_id}/reset-password",
response_model=AdminPasswordResetResponse,
dependencies=[Depends(require_admin)],
)
async def reset_user_password(
user_id: int, session: AsyncSession = Depends(get_session)
) -> AdminPasswordResetResponse:
"""Admin-only password reset for a user who's locked out: passwords are
Argon2-hashed (one-way), so an existing password can never be recovered or
displayed — this generates and sets a brand new one instead, shown once so
the admin can relay it to the user. There is no user-facing self-service
reset; only an admin (via /admin, token-gated) can trigger this."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
new_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(new_password)
await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id)
await session.commit()
return AdminPasswordResetResponse(username=user.username, new_password=new_password)
class AdminRoundResponse(BaseModel):
id: int
status: str
opened_at: str
closed_at: str | None
draw_block_height: int | None
draw_block_hash: str | None
winner_user_id: int | None
winner_username: str | None
pool_amount_sats: int | None
winner_amount_sats: int | None
fee_amount_sats: int | None
payout_txid: str | None
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = 50) -> list[AdminRoundResponse]:
rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all()
winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None}
winners = {}
if winner_ids:
users = (await session.scalars(select(User).where(User.id.in_(winner_ids)))).all()
winners = {u.id: u.username for u in users}
return [
AdminRoundResponse(
id=r.id,
status=r.status,
opened_at=r.opened_at.isoformat(),
closed_at=r.closed_at.isoformat() if r.closed_at else None,
draw_block_height=r.draw_block_height,
draw_block_hash=r.draw_block_hash,
winner_user_id=r.winner_user_id,
winner_username=winners.get(r.winner_user_id) if r.winner_user_id is not None else None,
pool_amount_sats=r.pool_amount_sats,
winner_amount_sats=r.winner_amount_sats,
fee_amount_sats=r.fee_amount_sats,
payout_txid=r.payout_txid,
)
for r in rounds
]
class AdminAuditLogResponse(BaseModel):
id: int
event_type: str
payload: dict
user_id: int | None
round_id: int | None
created_at: str
@router.get(
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
)
async def list_audit_log(
session: AsyncSession = Depends(get_session), limit: int = 200
) -> list[AdminAuditLogResponse]:
entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
return [
AdminAuditLogResponse(
id=e.id,
event_type=e.event_type,
payload=json.loads(e.payload_json),
user_id=e.user_id,
round_id=e.round_id,
created_at=e.created_at.isoformat(),
)
for e in entries
]
class AdminPendingTransactionResponse(BaseModel):
id: int
kind: str
status: str
round_id: int | None
withdrawal_id: int | None
user_id: int | None
current_txid: str
fee_rate_sat_vb: int
attempt_count: int
broadcast_at: str
replaced_by_txid: str | None
@router.get(
"/pending-transactions",
response_model=list[AdminPendingTransactionResponse],
dependencies=[Depends(require_admin)],
)
async def list_pending_transactions(
session: AsyncSession = Depends(get_session),
) -> list[AdminPendingTransactionResponse]:
entries = (
await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc()))
).all()
return [
AdminPendingTransactionResponse(
id=p.id,
kind=p.kind,
status=p.status,
round_id=p.round_id,
withdrawal_id=p.withdrawal_id,
user_id=p.user_id,
current_txid=p.current_txid,
fee_rate_sat_vb=p.fee_rate_sat_vb,
attempt_count=p.attempt_count,
broadcast_at=p.broadcast_at.isoformat(),
replaced_by_txid=p.replaced_by_txid,
)
for p in entries
]