80 lines
2.2 KiB
Python
80 lines
2.2 KiB
Python
"""Bootstrap endpoint — consumes the install token to create the first admin."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from flask import Blueprint, jsonify, make_response, request
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
from app.api._validation import Email
|
|
from sqlalchemy import select
|
|
|
|
from app.core.rate_limit import limiter
|
|
from app.db.session import session_scope
|
|
from app.models.auth import User
|
|
from app.services.bootstrap import (
|
|
BootstrapError,
|
|
bootstrap_admin,
|
|
ensure_system_groups,
|
|
)
|
|
|
|
bp = Blueprint("setup", __name__, url_prefix="/setup")
|
|
log = logging.getLogger("metamorph.api.setup")
|
|
|
|
|
|
class SetupPayload(BaseModel):
|
|
install_token: str = Field(min_length=20)
|
|
email: Email
|
|
password: str = Field(min_length=8)
|
|
display_name: str | None = None
|
|
|
|
|
|
@bp.get("")
|
|
def setup_status():
|
|
"""Tell the SPA whether the bootstrap has already been done.
|
|
|
|
Used by the front to redirect to /setup vs /login on first paint.
|
|
"""
|
|
with session_scope() as s:
|
|
any_user = s.scalar(select(User.id).limit(1)) is not None
|
|
return jsonify({"completed": any_user})
|
|
|
|
|
|
@bp.post("")
|
|
@limiter.limit("5 per minute")
|
|
def setup():
|
|
try:
|
|
payload = SetupPayload.model_validate(request.get_json(silent=True) or {})
|
|
except ValidationError as e:
|
|
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
|
|
|
try:
|
|
result = bootstrap_admin(
|
|
install_token=payload.install_token,
|
|
email=payload.email,
|
|
password=payload.password,
|
|
display_name=payload.display_name,
|
|
)
|
|
except BootstrapError as e:
|
|
return jsonify({"error": "bootstrap_failed", "message": str(e)}), 409
|
|
except ValueError as e:
|
|
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
|
|
|
log.warning(
|
|
"metamorph.bootstrap.completed",
|
|
extra={"user_id": str(result.user_id), "admin_group_id": str(result.admin_group_id)},
|
|
)
|
|
# Make sure the redteam/blueteam groups exist too (idempotent).
|
|
ensure_system_groups()
|
|
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"ok": True,
|
|
"user_id": str(result.user_id),
|
|
}
|
|
),
|
|
201,
|
|
)
|