Add controlled first-admin enrollment

This commit is contained in:
2026-08-04 10:07:10 +02:00
parent 40c10089ab
commit 25da7d49a9
15 changed files with 1541 additions and 3 deletions
+2
View File
@@ -8,6 +8,7 @@ from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.bootstrap import create_bootstrap_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.ownership import router as ownership_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
@@ -69,6 +70,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(create_bootstrap_router(server_config.settings))
api_router.include_router(credential_router)
api_router.include_router(ownership_router)
for router in server_config.post_module_routers:
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, Field, SecretStr
from sqlalchemy.orm import Session
from govoplan_core.core.access import (
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
FirstAdminProvisioner,
)
from govoplan_core.core.first_admin import (
FirstAdminEnrollmentConflict,
FirstAdminEnrollmentCredentialError,
FirstAdminEnrollmentUnavailable,
consume_first_admin_credential,
first_admin_enrollment_status,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_session
class FirstAdminReadinessResponse(BaseModel):
enrollment_required: bool
credential_active: bool
state: str
generation: int = 0
expires_at: datetime | None = None
readiness: dict[str, bool] = Field(default_factory=dict)
class FirstAdminEnrollmentRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
display_name: str | None = Field(default=None, max_length=255)
password: SecretStr = Field(min_length=12, max_length=1024)
tenant_slug: str = Field(default="default", min_length=1, max_length=100)
tenant_name: str = Field(default="Default Tenant", min_length=1, max_length=255)
class FirstAdminEnrollmentResponse(BaseModel):
account_id: str
membership_id: str | None = None
tenant_id: str | None = None
email: str
display_name: str | None = None
replayed: bool = False
bootstrap_retired: bool = True
def create_bootstrap_router(settings: object) -> APIRouter:
router = APIRouter(prefix="/bootstrap", tags=["bootstrap"])
@router.get("/status", response_model=FirstAdminReadinessResponse)
def bootstrap_status(
request: Request,
session: Session = Depends(get_session),
) -> FirstAdminReadinessResponse:
provisioner = _first_admin_provisioner(request, required=False)
if provisioner is None:
return FirstAdminReadinessResponse(
enrollment_required=False,
credential_active=False,
state="not_ready",
readiness={
"database": True,
"access_capability": False,
"administrator_absent": False,
},
)
enrollment = first_admin_enrollment_status(
session,
installation_id=str(getattr(settings, "installation_id", "govoplan-local")),
provisioner=provisioner,
)
return FirstAdminReadinessResponse(
enrollment_required=enrollment.enrollment_required,
credential_active=enrollment.credential_active,
state=enrollment.state,
generation=enrollment.generation,
expires_at=enrollment.expires_at,
readiness=enrollment.readiness,
)
@router.post(
"/first-admin",
response_model=FirstAdminEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_first_admin(
payload: FirstAdminEnrollmentRequest,
request: Request,
x_govoplan_enrollment_token: str = Header(
min_length=32,
max_length=512,
alias="X-GovOPlaN-Enrollment-Token",
),
session: Session = Depends(get_session),
) -> FirstAdminEnrollmentResponse:
provisioner = _first_admin_provisioner(request, required=True)
assert provisioner is not None
try:
result = consume_first_admin_credential(
session,
installation_id=str(getattr(settings, "installation_id", "govoplan-local")),
provisioner=provisioner,
secret=x_govoplan_enrollment_token,
email=payload.email,
display_name=payload.display_name,
password=payload.password.get_secret_value(),
tenant_slug=payload.tenant_slug,
tenant_name=payload.tenant_name,
)
session.commit()
except FirstAdminEnrollmentCredentialError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except FirstAdminEnrollmentUnavailable as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_410_GONE, detail=str(exc)) from exc
except FirstAdminEnrollmentConflict as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
administrator = result.administrator
return FirstAdminEnrollmentResponse(
account_id=administrator.account_id,
membership_id=administrator.membership_id,
tenant_id=administrator.tenant_id,
email=administrator.email,
display_name=administrator.display_name,
replayed=result.replayed,
)
return router
def _first_admin_provisioner(
request: Request,
*,
required: bool,
) -> FirstAdminProvisioner | None:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
if required:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The module registry is not ready.",
)
return None
if not registry.has_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER):
if required:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Install and enable the Access module before enrolling the first administrator.",
)
return None
capability = registry.require_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER)
if not isinstance(capability, FirstAdminProvisioner):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="The Access first-administrator capability is invalid.",
)
return capability
__all__ = [
"FirstAdminEnrollmentRequest",
"FirstAdminEnrollmentResponse",
"FirstAdminReadinessResponse",
"create_bootstrap_router",
]