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>
This commit is contained in:
+57
-13
@@ -2,7 +2,7 @@ import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -12,16 +12,26 @@ 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:
|
||||
if not settings.admin_token or x_admin_token != settings.admin_token:
|
||||
# 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",
|
||||
@@ -30,7 +40,6 @@ _CONFIG_FIELDS = (
|
||||
"fee_rate_sat_vb",
|
||||
"rbf_timeout_seconds",
|
||||
"draw_animation_seconds",
|
||||
"paused",
|
||||
)
|
||||
|
||||
|
||||
@@ -46,18 +55,41 @@ class RoundConfigResponse(BaseModel):
|
||||
|
||||
|
||||
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 = None
|
||||
round_duration_seconds: int | None = None
|
||||
round_cooldown_seconds: int | None = None
|
||||
fee_rate_sat_vb: int | None = None
|
||||
rbf_timeout_seconds: int | None = None
|
||||
draw_animation_seconds: int | None = None
|
||||
paused: bool | 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:
|
||||
return RoundConfigResponse(**{field: getattr(config, field) for field in _CONFIG_FIELDS})
|
||||
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)])
|
||||
@@ -72,10 +104,22 @@ 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 not None:
|
||||
setattr(config, field, value)
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user