Add admin endpoints to list users and export a user's private key
GET /admin/users lists id/username/address/balance_sats/created_at.
GET /admin/users/{id}/privkey derives and returns that user's raw WIF
private key, for manual intervention (e.g. sweeping funds back if
something's stuck) — this is already a custodial system, the server
holds the master key everything is derived from, so this doesn't grant
a new capability, just exposes an existing one through the API. Every
access is audit-logged (admin_privkey_accessed).
Also fixes a pre-existing test-isolation bug in test_admin.py's client
fixture: app.db.session.get_session had `from app.db.base import
AsyncSessionLocal`, a one-time reference copy at first import — later
tests reassigning db_base.AsyncSessionLocal never reached it, so any
test mixing direct DB writes with router calls silently read/wrote
against a stale, possibly-disposed engine from whichever test ran
first. Fixed by also rebinding app.db.session.AsyncSessionLocal in the
fixture on every run.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.config import settings
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.wallet.hd import derive_user_wif
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -42,3 +46,48 @@ async def update_config(
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
from embit import script
|
||||
from embit.bip32 import HDKey
|
||||
from embit.ec import PrivateKey
|
||||
|
||||
from app.config import settings
|
||||
from app.wallet.keystore import decrypt_xprv, encrypt_xprv
|
||||
@@ -39,6 +40,16 @@ def derive_user_address(derivation_index: int) -> str:
|
||||
return script.p2wpkh(pub).address(network=PLM_MAINNET)
|
||||
|
||||
|
||||
def derive_user_wif(derivation_index: int) -> str:
|
||||
"""Exports a user's raw private key (WIF) for manual server-side intervention
|
||||
(e.g. sweeping funds back to a user, or out, if something gets stuck). This is
|
||||
a custodial system — the server already holds the master key this is derived
|
||||
from — but callers must still treat the result as a live secret: log access,
|
||||
never persist it, never return it over an unauthenticated channel."""
|
||||
key = derive_user_key(derivation_index)
|
||||
return PrivateKey(key.secret, compressed=True, network=PLM_MAINNET).wif(network=PLM_MAINNET)
|
||||
|
||||
|
||||
def derive_pool_key() -> HDKey:
|
||||
"""The "indirizzo padre" from the flowchart: all bets are sent here, and
|
||||
payouts are signed with this key. Reserved on branch 1 of the account (branch 0
|
||||
|
||||
Reference in New Issue
Block a user