feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""Administrator-only encrypted backup downloads and staged restores."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..db import get_rate_limit_status, record_rate_limit_event
|
||||
from ..services import backups
|
||||
|
||||
def _no_store(response: Response) -> None:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/backups", tags=["backups"],
|
||||
dependencies=[Depends(require_admin), Depends(_no_store)],
|
||||
)
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
passphrase: SecretStr = Field(min_length=12, max_length=1024)
|
||||
include_cache: bool = False
|
||||
|
||||
|
||||
def _rate_limit(user: dict) -> None:
|
||||
key = str(user["username"])
|
||||
exceeded, retry = get_rate_limit_status("backups", key, 300, 3)
|
||||
if exceeded:
|
||||
raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)})
|
||||
record_rate_limit_event("backups", key)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def status() -> dict:
|
||||
return backups.backup_status()
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response:
|
||||
_rate_limit(user)
|
||||
try:
|
||||
content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache)
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return Response(content, media_type="application/octet-stream", headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "no-store", "Pragma": "no-cache",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/restore", status_code=202)
|
||||
async def restore(
|
||||
file: UploadFile = File(...),
|
||||
passphrase: str = Form(..., min_length=12, max_length=1024),
|
||||
confirmation: Literal["RESTORE"] = Form(...),
|
||||
user: dict = Depends(require_admin),
|
||||
) -> dict:
|
||||
_rate_limit(user)
|
||||
try:
|
||||
if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "Backup exceeds the 32 MiB upload limit")
|
||||
metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase)
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
finally:
|
||||
await file.close()
|
||||
return {
|
||||
"status": "staged", "restart_required": True, "backup": metadata,
|
||||
"message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/restore")
|
||||
def cancel() -> dict:
|
||||
try:
|
||||
backups.cancel_restore()
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
return {"status": "cancelled"}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Initial install bootstrap and authenticated setup wizard endpoints."""
|
||||
|
||||
from inspect import isawaitable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import Field, SecretStr
|
||||
|
||||
from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest
|
||||
from ..auth import _extract_client_ip, require_admin
|
||||
from ..services import setup as setup_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES)
|
||||
|
||||
|
||||
class BootstrapRequest(StrictRequest):
|
||||
setup_token: SecretStr = Field(min_length=1, max_length=1024)
|
||||
username: str = Field(min_length=1, max_length=100)
|
||||
password: SecretStr = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class SetupProgress(StrictRequest):
|
||||
step: setup_service.SetupStep
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def public_status(response: Response) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return setup_service.get_public_setup_status()
|
||||
|
||||
|
||||
@router.post("/bootstrap", status_code=201)
|
||||
def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
|
||||
status = setup_service.get_public_setup_status()
|
||||
if not status["needs_admin"]:
|
||||
raise HTTPException(status_code=409, detail="Initial administrator setup is no longer available.")
|
||||
retry_after = setup_service.consume_bootstrap_attempt(_extract_client_ip(request))
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many setup attempts. Try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
try:
|
||||
setup_service.bootstrap_administrator(
|
||||
payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value()
|
||||
)
|
||||
except setup_service.InvalidSetupTokenError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except setup_service.SetupUnavailableError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"status": "created", "username": payload.username.strip()}
|
||||
|
||||
|
||||
@router.get("/state", dependencies=[Depends(require_admin)])
|
||||
def get_state() -> dict:
|
||||
return setup_service.get_setup_state()
|
||||
|
||||
|
||||
@router.put("/state", dependencies=[Depends(require_admin)])
|
||||
def update_state(payload: SetupProgress) -> dict:
|
||||
return setup_service.update_setup_step(payload.step)
|
||||
|
||||
|
||||
@router.post("/complete", dependencies=[Depends(require_admin)])
|
||||
async def finish_setup(request: Request) -> dict:
|
||||
try:
|
||||
state = setup_service.complete_setup()
|
||||
except setup_service.SetupUnavailableError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
# Startup owns worker lifecycle. Its callback must be idempotent so retries
|
||||
# after a network interruption cannot start duplicate import/automation jobs.
|
||||
callback = getattr(request.app.state, "on_setup_complete", None)
|
||||
if callback is not None:
|
||||
result = callback()
|
||||
if isawaitable(result):
|
||||
await result
|
||||
return state
|
||||
Reference in New Issue
Block a user