Add controlled first-admin enrollment
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user