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
+34
View File
@@ -21,6 +21,7 @@ CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
@@ -45,6 +46,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_ACCESS_EXPLANATION,
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
@@ -342,6 +344,19 @@ class DevelopmentBootstrapRef:
created_api_key: CreatedApiKeyRef | None = None
@dataclass(frozen=True, slots=True)
class FirstSystemAdministratorRef:
account_id: str
email: str
display_name: str | None = None
membership_id: str | None = None
tenant_id: str | None = None
class FirstAdminProvisioningError(RuntimeError):
"""Safe, user-facing rejection from the Access enrollment boundary."""
@dataclass(frozen=True, slots=True)
class TenantContextSwitchRef:
account_id: str
@@ -579,6 +594,25 @@ class TenantAccessProvisioner(Protocol):
...
@runtime_checkable
class FirstAdminProvisioner(Protocol):
"""Narrow Access boundary used only by the production bootstrap flow."""
def has_durable_system_administrator(self, session: object) -> bool:
...
def create_first_system_administrator(
self,
session: object,
*,
tenant: object,
email: str,
display_name: str | None,
password: str,
) -> FirstSystemAdministratorRef:
...
@runtime_checkable
class AccessAdministration(Protocol):
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
+532
View File
@@ -0,0 +1,532 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import hmac
import json
import re
import secrets
from typing import Any
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.access import (
FirstAdminProvisioner,
FirstAdminProvisioningError,
FirstSystemAdministratorRef,
)
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.tenancy.scope import Tenant
_TENANT_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class FirstAdminEnrollmentState(StrEnum):
INACTIVE = "inactive"
ACTIVE = "active"
CONSUMED = "consumed"
REVOKED = "revoked"
class FirstAdminEnrollmentError(RuntimeError):
pass
class FirstAdminEnrollmentUnavailable(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollmentCredentialError(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollmentConflict(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollment(Base, TimestampMixin):
__tablename__ = "core_first_admin_enrollments"
installation_id: Mapped[str] = mapped_column(String(100), primary_key=True)
state: Mapped[str] = mapped_column(
String(24),
default=FirstAdminEnrollmentState.INACTIVE.value,
nullable=False,
index=True,
)
generation: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
token_sha256: Mapped[str | None] = mapped_column(String(64))
token_fingerprint: Mapped[str | None] = mapped_column(String(16))
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
consumed_account_id: Mapped[str | None] = mapped_column(String(36))
consumed_membership_id: Mapped[str | None] = mapped_column(String(36))
consumed_tenant_id: Mapped[str | None] = mapped_column(String(36))
consumed_email: Mapped[str | None] = mapped_column(String(320))
consumed_display_name: Mapped[str | None] = mapped_column(String(255))
consumed_request_sha256: Mapped[str | None] = mapped_column(String(64))
issue_reason: Mapped[str | None] = mapped_column(String(500))
event_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
class FirstAdminEnrollmentEvent(Base):
__tablename__ = "core_first_admin_enrollment_events"
__table_args__ = (
UniqueConstraint(
"installation_id",
"sequence",
name="uq_core_first_admin_enrollment_event_sequence",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(
ForeignKey(
"core_first_admin_enrollments.installation_id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
generation: Mapped[int] = mapped_column(Integer, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
previous_sha256: Mapped[str | None] = mapped_column(String(64))
event_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
nullable=False,
)
@dataclass(frozen=True, slots=True)
class IssuedFirstAdminCredential:
secret: str
fingerprint: str
generation: int
expires_at: datetime
@dataclass(frozen=True, slots=True)
class FirstAdminEnrollmentStatus:
enrollment_required: bool
credential_active: bool
state: str
generation: int
expires_at: datetime | None
completed_account_id: str | None
readiness: dict[str, bool]
@dataclass(frozen=True, slots=True)
class FirstAdminEnrollmentResult:
administrator: FirstSystemAdministratorRef
replayed: bool
def issue_first_admin_credential(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
ttl_seconds: int,
reason: str,
replace_active: bool = False,
now: datetime | None = None,
) -> IssuedFirstAdminCredential:
current_time = _utc(now)
if ttl_seconds < 60 or ttl_seconds > 24 * 60 * 60:
raise ValueError("First-admin enrollment expiry must be between 60 seconds and 24 hours.")
if provisioner.has_durable_system_administrator(session):
raise FirstAdminEnrollmentUnavailable(
"A durable system administrator already exists. Bootstrap enrollment is disabled."
)
enrollment = _locked_enrollment(session, installation_id)
if (
enrollment.state == FirstAdminEnrollmentState.ACTIVE.value
and _is_future(enrollment.expires_at, current_time)
and not replace_active
):
raise FirstAdminEnrollmentConflict(
"An unexpired first-admin credential already exists. Use the recovery command to rotate it."
)
secret = secrets.token_urlsafe(48)
token_sha256 = _secret_sha256(secret)
fingerprint = token_sha256[:12]
expires_at = current_time + timedelta(seconds=ttl_seconds)
generation = enrollment.generation + 1
if enrollment.state == FirstAdminEnrollmentState.ACTIVE.value:
_append_event(
session,
enrollment,
event_type="credential_revoked",
generation=enrollment.generation,
created_at=current_time,
evidence={"reason": "local_operator_recovery"},
)
enrollment.state = FirstAdminEnrollmentState.ACTIVE.value
enrollment.generation = generation
enrollment.token_sha256 = token_sha256
enrollment.token_fingerprint = fingerprint
enrollment.issued_at = current_time
enrollment.expires_at = expires_at
enrollment.consumed_at = None
enrollment.consumed_account_id = None
enrollment.consumed_membership_id = None
enrollment.consumed_tenant_id = None
enrollment.consumed_email = None
enrollment.consumed_display_name = None
enrollment.consumed_request_sha256 = None
enrollment.issue_reason = _bounded_reason(reason)
session.add(enrollment)
_append_event(
session,
enrollment,
event_type="credential_issued",
generation=generation,
created_at=current_time,
evidence={
"fingerprint": fingerprint,
"expires_at": expires_at.isoformat(),
"reason": enrollment.issue_reason,
},
)
audit_event(
session,
tenant_id=None,
scope="system",
action="access.first_admin_enrollment.issued",
object_type="first_admin_enrollment",
object_id=installation_id,
details={
"generation": generation,
"fingerprint": fingerprint,
"expires_at": expires_at.isoformat(),
"reason": enrollment.issue_reason,
},
)
return IssuedFirstAdminCredential(
secret=secret,
fingerprint=fingerprint,
generation=generation,
expires_at=expires_at,
)
def first_admin_enrollment_status(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
now: datetime | None = None,
) -> FirstAdminEnrollmentStatus:
current_time = _utc(now)
administrator_exists = provisioner.has_durable_system_administrator(session)
enrollment = session.get(FirstAdminEnrollment, installation_id)
state = enrollment.state if enrollment is not None else FirstAdminEnrollmentState.INACTIVE.value
active = bool(
not administrator_exists
and enrollment is not None
and state == FirstAdminEnrollmentState.ACTIVE.value
and enrollment.token_sha256
and _is_future(enrollment.expires_at, current_time)
)
if (
not administrator_exists
and enrollment is not None
and state == FirstAdminEnrollmentState.ACTIVE.value
and not active
):
state = "expired"
return FirstAdminEnrollmentStatus(
enrollment_required=not administrator_exists,
credential_active=active,
state="completed" if administrator_exists else state,
generation=enrollment.generation if enrollment is not None else 0,
expires_at=enrollment.expires_at if enrollment is not None else None,
completed_account_id=(
enrollment.consumed_account_id if enrollment is not None else None
),
readiness={
"database": True,
"access_capability": True,
"administrator_absent": not administrator_exists,
},
)
def consume_first_admin_credential(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
secret: str,
email: str,
display_name: str | None,
password: str,
tenant_slug: str,
tenant_name: str,
now: datetime | None = None,
) -> FirstAdminEnrollmentResult:
current_time = _utc(now)
normalized_email = email.strip().casefold()
clean_display_name = display_name.strip() if display_name and display_name.strip() else None
clean_tenant_slug = tenant_slug.strip().casefold()
clean_tenant_name = tenant_name.strip()
if not normalized_email or "@" not in normalized_email:
raise FirstAdminEnrollmentConflict("Enter a valid administrator email address.")
if len(password) < 12:
raise FirstAdminEnrollmentConflict("The administrator password must contain at least 12 characters.")
if not _TENANT_SLUG_RE.fullmatch(clean_tenant_slug):
raise FirstAdminEnrollmentConflict(
"The initial tenant slug may contain lowercase letters, numbers, and single hyphens."
)
if not clean_tenant_name:
raise FirstAdminEnrollmentConflict("Enter a name for the initial tenant.")
request_sha256 = _request_sha256(
email=normalized_email,
display_name=clean_display_name,
tenant_slug=clean_tenant_slug,
tenant_name=clean_tenant_name,
)
supplied_sha256 = _secret_sha256(secret)
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one_or_none()
if enrollment is None:
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
if enrollment.state == FirstAdminEnrollmentState.CONSUMED.value:
if (
enrollment.token_sha256
and hmac.compare_digest(enrollment.token_sha256, supplied_sha256)
and enrollment.consumed_request_sha256 == request_sha256
and enrollment.consumed_account_id
and enrollment.consumed_email
):
return FirstAdminEnrollmentResult(
administrator=FirstSystemAdministratorRef(
account_id=enrollment.consumed_account_id,
email=enrollment.consumed_email,
display_name=enrollment.consumed_display_name,
membership_id=enrollment.consumed_membership_id,
tenant_id=enrollment.consumed_tenant_id,
),
replayed=True,
)
raise FirstAdminEnrollmentCredentialError("The first-admin credential has already been used.")
if enrollment.state != FirstAdminEnrollmentState.ACTIVE.value or not enrollment.token_sha256:
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
if not _is_future(enrollment.expires_at, current_time):
raise FirstAdminEnrollmentCredentialError(
"The first-admin credential has expired. A local operator must issue a replacement."
)
if not hmac.compare_digest(enrollment.token_sha256, supplied_sha256):
raise FirstAdminEnrollmentCredentialError("The first-admin credential is invalid.")
if provisioner.has_durable_system_administrator(session):
raise FirstAdminEnrollmentUnavailable(
"A durable system administrator already exists. Bootstrap enrollment is disabled."
)
tenant = session.execute(
select(Tenant).where(Tenant.slug == clean_tenant_slug).with_for_update()
).scalar_one_or_none()
if tenant is None:
tenant = Tenant(
slug=clean_tenant_slug,
name=clean_tenant_name,
default_locale="en",
settings={},
is_active=True,
)
session.add(tenant)
session.flush()
elif not tenant.is_active:
raise FirstAdminEnrollmentConflict("The selected initial tenant is inactive.")
try:
administrator = provisioner.create_first_system_administrator(
session,
tenant=tenant,
email=normalized_email,
display_name=clean_display_name,
password=password,
)
except FirstAdminProvisioningError as exc:
raise FirstAdminEnrollmentConflict(str(exc)) from exc
enrollment.state = FirstAdminEnrollmentState.CONSUMED.value
enrollment.consumed_at = current_time
enrollment.consumed_account_id = administrator.account_id
enrollment.consumed_membership_id = administrator.membership_id
enrollment.consumed_tenant_id = administrator.tenant_id
enrollment.consumed_email = administrator.email
enrollment.consumed_display_name = administrator.display_name
enrollment.consumed_request_sha256 = request_sha256
session.add(enrollment)
_append_event(
session,
enrollment,
event_type="administrator_created",
generation=enrollment.generation,
created_at=current_time,
evidence={
"account_id": administrator.account_id,
"membership_id": administrator.membership_id,
"tenant_id": administrator.tenant_id,
"email_sha256": hashlib.sha256(normalized_email.encode("utf-8")).hexdigest(),
},
)
audit_event(
session,
tenant_id=None,
scope="system",
action="access.first_admin_enrollment.completed",
object_type="access_account",
object_id=administrator.account_id,
details={
"generation": enrollment.generation,
"membership_id": administrator.membership_id,
"tenant_id": administrator.tenant_id,
"credential_invalidated": True,
},
)
return FirstAdminEnrollmentResult(administrator=administrator, replayed=False)
def _locked_enrollment(session: Session, installation_id: str) -> FirstAdminEnrollment:
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one_or_none()
if enrollment is not None:
return enrollment
enrollment = FirstAdminEnrollment(installation_id=installation_id)
try:
with session.begin_nested():
session.add(enrollment)
session.flush()
except IntegrityError:
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one()
return enrollment
def _append_event(
session: Session,
enrollment: FirstAdminEnrollment,
*,
event_type: str,
generation: int,
created_at: datetime,
evidence: dict[str, Any],
) -> None:
sequence = enrollment.event_count + 1
payload = {
"installation_id": enrollment.installation_id,
"sequence": sequence,
"event_type": event_type,
"generation": generation,
"created_at": created_at.isoformat(),
"evidence": evidence,
"previous_sha256": enrollment.evidence_head_sha256,
}
event_sha256 = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
session.add(
FirstAdminEnrollmentEvent(
installation_id=enrollment.installation_id,
sequence=sequence,
event_type=event_type,
generation=generation,
evidence=evidence,
previous_sha256=enrollment.evidence_head_sha256,
event_sha256=event_sha256,
created_at=created_at,
)
)
enrollment.event_count = sequence
enrollment.evidence_head_sha256 = event_sha256
session.add(enrollment)
def _request_sha256(
*,
email: str,
display_name: str | None,
tenant_slug: str,
tenant_name: str,
) -> str:
payload = {
"email": email,
"display_name": display_name,
"tenant_slug": tenant_slug,
"tenant_name": tenant_name,
}
return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def _secret_sha256(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def _utc(value: datetime | None) -> datetime:
candidate = value or datetime.now(timezone.utc)
if candidate.tzinfo is None:
return candidate.replace(tzinfo=timezone.utc)
return candidate.astimezone(timezone.utc)
def _is_future(value: datetime | None, now: datetime) -> bool:
return value is not None and _utc(value) > now
def _bounded_reason(value: str) -> str:
clean = value.strip()
if not clean:
raise ValueError("A local operator reason is required.")
return clean[:500]
__all__ = [
"FirstAdminEnrollment",
"FirstAdminEnrollmentConflict",
"FirstAdminEnrollmentCredentialError",
"FirstAdminEnrollmentError",
"FirstAdminEnrollmentEvent",
"FirstAdminEnrollmentResult",
"FirstAdminEnrollmentState",
"FirstAdminEnrollmentStatus",
"FirstAdminEnrollmentUnavailable",
"IssuedFirstAdminCredential",
"consume_first_admin_credential",
"first_admin_enrollment_status",
"issue_first_admin_credential",
]