Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto the round when it opened. The cost of a bet and the fee address were read live, so an edit from /admin in the middle of a round gave the players of that round different stakes for the same chance and redirected the commission of the round in progress. - `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column, existing rounds backfilled with the current configuration, i.e. what they were using; the expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the round's fee address (an empty one on the row still stops the payout, as before), `GET /rounds/current` advertises the round's cost while one is active and the configured one otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10). - `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included, has reached the round's cap, before anything moves; the pool can exceed it by one bet at most. A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`. - PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the bet cost the round in progress opened with, which can be lower than the configured one. - `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the seven translations are generic. Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and "applies immediately, also to the round in progress" in the round_config table of modello-dati; the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line). Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and the old "fee address missing" payout test now empties the round's address (clearing it in the configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open, edits reaching only the next round, every bet of a round paying the same stake whether the cost is raised or lowered meanwhile, the payout following the round's address (and surviving it being cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap, a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route, the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the migration backfill. Mutation-checked: charging the live cost, paying the live address, not copying the cap and `>` instead of `>=` each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
518 lines
20 KiB
Python
518 lines
20 KiB
Python
import json
|
|
import secrets
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.errors import http_error
|
|
from app.api.timeutil import isoformat_utc
|
|
from app.audit.log import write_audit_log
|
|
from app.auth.security import hash_password_async
|
|
from app.config import settings
|
|
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
|
|
from app.db.session import get_session
|
|
from app.rounds.config import get_round_config
|
|
from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON
|
|
from app.rounds.service import get_active_round, payout_config_problem
|
|
from app.wallet.address import is_valid_plm_address
|
|
from app.wallet.hd import derive_user_wif
|
|
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
|
|
|
|
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")
|
|
# compare_digest raises TypeError on a str containing non-ASCII characters
|
|
# (B-46) -- comparing the UTF-8 bytes instead accepts any input safely.
|
|
if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()):
|
|
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",
|
|
"deposit_confirmations", # B-82 (DAT-62)
|
|
"draw_confirmations", # B-73 (DAT-62)
|
|
"max_round_pool_sats", # B-95 (DAT-62)
|
|
)
|
|
|
|
|
|
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
|
|
deposit_confirmations: int # B-82 (DAT-62)
|
|
draw_confirmations: int # B-73 (DAT-62)
|
|
max_round_pool_sats: int # B-95 (DAT-62)
|
|
paused: bool
|
|
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
|
|
|
|
|
|
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=MAX_FEE_RATE_SAT_VB)
|
|
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)
|
|
deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62)
|
|
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
|
|
max_round_pool_sats: int | None = Field(default=None, ge=0, le=4_000_000_000_000_000) # B-95 (DAT-62)
|
|
|
|
@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
|
|
fields["paused_reason"] = config.paused_reason
|
|
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)
|
|
|
|
# B-79 (RND-40): a bet amount or fee rate under which a round could not be paid out is
|
|
# refused before anything is written — the payout reads fee_rate_sat_vb at build time, so
|
|
# a rate raised mid-round could otherwise make the round in progress unpayable.
|
|
if "bet_amount_sats" in changes or "fee_rate_sat_vb" in changes:
|
|
amounts = {config.bet_amount_sats}
|
|
if "fee_rate_sat_vb" in changes:
|
|
# B-95: the round in progress is paid with the bet amount it opened with, which may
|
|
# differ from the one configured now — a higher fee rate has to leave *it* payable too.
|
|
active = await get_active_round(session)
|
|
if active is not None:
|
|
amounts.add(active.bet_amount_sats)
|
|
problem = next(
|
|
(p for p in (payout_config_problem(a, config.fee_rate_sat_vb) for a in sorted(amounts)) if p), None
|
|
)
|
|
if problem is not None:
|
|
await session.rollback()
|
|
raise http_error(
|
|
422,
|
|
"config_not_payable",
|
|
"this bet amount and fee rate would make a round impossible to pay out "
|
|
f"({problem}); a larger bet amount or a lower fee rate is needed",
|
|
reason=problem,
|
|
)
|
|
|
|
# B-95 (DAT-62): a cap below the price of one bet would refuse every bet of every round.
|
|
if (
|
|
("max_round_pool_sats" in changes or "bet_amount_sats" in changes)
|
|
and config.max_round_pool_sats
|
|
and config.max_round_pool_sats < config.bet_amount_sats
|
|
):
|
|
offending = {"max_round_pool_sats": config.max_round_pool_sats, "bet_amount_sats": config.bet_amount_sats}
|
|
await session.rollback()
|
|
raise http_error(
|
|
422,
|
|
"max_round_pool_below_bet_amount",
|
|
"max_round_pool_sats must be 0 (no cap) or at least the bet amount",
|
|
**offending,
|
|
)
|
|
|
|
if changes:
|
|
await write_audit_log(session, "config_updated", changes)
|
|
await session.commit()
|
|
return _config_response(config)
|
|
|
|
|
|
class PauseRequest(BaseModel):
|
|
note: str | None = Field(default=None, max_length=256)
|
|
|
|
|
|
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
|
async def pause_lottery(
|
|
body: PauseRequest | None = None, 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).
|
|
|
|
B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason
|
|
already in force stays — it is the one that needs a look. The audit row always
|
|
says `operator` for this call."""
|
|
config = await get_round_config(session)
|
|
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
|
|
config.paused_reason = OPERATOR_REASON
|
|
config.paused = True
|
|
payload = {"reason": OPERATOR_REASON}
|
|
if body is not None and body.note:
|
|
payload["note"] = body.note
|
|
await write_audit_log(session, "lottery_paused", payload)
|
|
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)
|
|
# B-78 (AUD-12): the reason the resume took away, so the log says what the operator
|
|
# agreed to resume (None when the lottery was not paused).
|
|
lifted = config.paused_reason if config.paused else None
|
|
config.paused = False
|
|
config.paused_reason = None
|
|
# B-82 (DAT-68, API-32): a payout held because the winner's bet left the chain is released
|
|
# for a new attempt, which verifies the coins again, in the same transaction as the resume.
|
|
await session.execute(update(Round).where(Round.payout_blocked_at.is_not(None)).values(payout_blocked_at=None))
|
|
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
|
|
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=isoformat_utc(u.created_at),
|
|
)
|
|
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 = await hash_password_async(new_password)
|
|
# B-34: this endpoint exists precisely for the "account compromised" case —
|
|
# without bumping token_version, whoever was already logged in (the
|
|
# attacker, if that's who prompted the reset) stayed logged in on their
|
|
# existing token until it naturally expired, unaffected by the reset.
|
|
user.token_version += 1
|
|
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
|
|
pool_address: str | None # B-80
|
|
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
|
|
draw_height: int | None # B-73
|
|
bump_reserve_sats: int | None # B-79 (API-36)
|
|
payout_blocked_at: str | None # B-82 (API-36, DAT-68)
|
|
|
|
|
|
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
|
|
async def list_rounds(
|
|
session: AsyncSession = Depends(get_session), limit: int = Query(default=50, ge=1, le=500)
|
|
) -> 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=isoformat_utc(r.opened_at),
|
|
closed_at=isoformat_utc(r.closed_at),
|
|
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,
|
|
pool_address=r.pool_address or None,
|
|
server_seed_hash=r.server_seed_hash,
|
|
draw_height=r.draw_height,
|
|
bump_reserve_sats=r.bump_reserve_sats,
|
|
payout_blocked_at=isoformat_utc(r.payout_blocked_at),
|
|
)
|
|
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 = Query(default=200, ge=1, le=500)
|
|
) -> 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=isoformat_utc(e.created_at),
|
|
)
|
|
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),
|
|
limit: int = Query(default=50, ge=1, le=500),
|
|
status_filter: str | None = Query(default=None, alias="status"),
|
|
) -> list[AdminPendingTransactionResponse]:
|
|
query = select(PendingTransaction).order_by(PendingTransaction.id.desc())
|
|
if status_filter is not None:
|
|
query = query.where(PendingTransaction.status == status_filter)
|
|
entries = (await session.scalars(query.limit(limit))).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=isoformat_utc(p.broadcast_at),
|
|
replaced_by_txid=p.replaced_by_txid,
|
|
)
|
|
for p in entries
|
|
]
|
|
|
|
|
|
_BUG_REPORT_STATUSES = ("open", "read", "resolved")
|
|
|
|
|
|
class AdminBugReportResponse(BaseModel):
|
|
id: int
|
|
description: str
|
|
contact: str | None
|
|
user_id: int | None
|
|
username: str | None
|
|
status: str
|
|
created_at: str
|
|
|
|
|
|
def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse:
|
|
return AdminBugReportResponse(
|
|
id=report.id,
|
|
description=report.description,
|
|
contact=report.contact,
|
|
user_id=report.user_id,
|
|
username=username,
|
|
status=report.status,
|
|
created_at=isoformat_utc(report.created_at),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)]
|
|
)
|
|
async def list_bug_reports(
|
|
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
|
|
) -> list[AdminBugReportResponse]:
|
|
reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all()
|
|
user_ids = {r.user_id for r in reports if r.user_id is not None}
|
|
usernames = {}
|
|
if user_ids:
|
|
users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all()
|
|
usernames = {u.id: u.username for u in users}
|
|
|
|
return [
|
|
_bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None)
|
|
for r in reports
|
|
]
|
|
|
|
|
|
class BugReportStatusUpdate(BaseModel):
|
|
status: str = Field(pattern="^(open|read|resolved)$")
|
|
|
|
|
|
@router.post(
|
|
"/bug-reports/{report_id}/status",
|
|
response_model=AdminBugReportResponse,
|
|
dependencies=[Depends(require_admin)],
|
|
)
|
|
async def update_bug_report_status(
|
|
report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session)
|
|
) -> AdminBugReportResponse:
|
|
report = await session.get(BugReport, report_id)
|
|
if report is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
|
|
|
|
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
|
|
# password reset) leaves a trace; this one silently marked a report `resolved`.
|
|
# With one shared ADMIN_TOKEN and no per-admin identity, the audit log is the
|
|
# only accountability there is. Before/after like config_updated, and nothing
|
|
# written when the status doesn't actually change — re-clicking the status a
|
|
# report already has isn't an event.
|
|
if report.status != body.status:
|
|
await write_audit_log(
|
|
session,
|
|
"bug_report_status_changed",
|
|
{"report_id": report_id, "from": report.status, "to": body.status},
|
|
user_id=report.user_id,
|
|
)
|
|
report.status = body.status
|
|
await session.commit()
|
|
|
|
username = None
|
|
if report.user_id is not None:
|
|
user = await session.get(User, report.user_id)
|
|
username = user.username if user is not None else None
|
|
|
|
return _bug_report_response(report, username)
|