Provide first system administrator authority

This commit is contained in:
2026-08-04 10:07:20 +02:00
parent e710cf5fb8
commit 668f7cf108
3 changed files with 263 additions and 2 deletions
@@ -5,11 +5,21 @@ from collections.abc import Mapping, Sequence
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
from govoplan_access.backend.db.models import Account, SystemRoleAssignment, User, UserRoleAssignment
from govoplan_access.backend.db.models import Account, Role, SystemRoleAssignment, User, UserRoleAssignment
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant
from govoplan_access.backend.security.api_keys import create_api_key
from govoplan_access.backend.security.passwords import hash_password
from govoplan_core.admin.common import AdminValidationError
from govoplan_core.core.access import CreatedApiKeyRef, DevelopmentBootstrapRef, TenantAccessProvisioner, TenantOwnerCandidateRef, UserRef
from govoplan_core.core.access import (
CreatedApiKeyRef,
DevelopmentBootstrapRef,
FirstAdminProvisioner,
FirstAdminProvisioningError,
FirstSystemAdministratorRef,
TenantAccessProvisioner,
TenantOwnerCandidateRef,
UserRef,
)
class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
@@ -169,6 +179,108 @@ class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
)
class LegacyFirstAdminProvisioner(FirstAdminProvisioner):
def has_durable_system_administrator(self, session: object) -> bool:
db = _session(session)
roles = (
db.query(Role)
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
.join(Account, Account.id == SystemRoleAssignment.account_id)
.filter(Role.tenant_id.is_(None), Account.is_active.is_(True))
.all()
)
return any(
role.slug in {"system_owner", "system_admin"}
or scopes_grant(role.permissions or (), "access:system_setting:write")
for role in roles
)
def create_first_system_administrator(
self,
session: object,
*,
tenant: object,
email: str,
display_name: str | None,
password: str,
) -> FirstSystemAdministratorRef:
db = _session(session)
tenant_id = getattr(tenant, "id", None)
if not tenant_id:
raise FirstAdminProvisioningError(
"First-administrator enrollment requires a persisted initial tenant."
)
if len(password) < 12:
raise FirstAdminProvisioningError(
"The administrator password must contain at least 12 characters."
)
if self.has_durable_system_administrator(db):
raise FirstAdminProvisioningError(
"A durable system administrator already exists."
)
normalized_email = normalize_email(email)
if not normalized_email or "@" not in normalized_email:
raise FirstAdminProvisioningError("Enter a valid administrator email address.")
existing = (
db.query(Account)
.filter(Account.normalized_email == normalized_email)
.with_for_update()
.one_or_none()
)
if existing is not None:
raise FirstAdminProvisioningError(
"The enrollment email already belongs to an account. Use a new address for the first system owner."
)
tenant_roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
system_roles = ensure_default_roles(db, None)
account, created, _temporary_password = get_or_create_account(
db,
email=email,
display_name=display_name,
password=password,
password_reset_required=False,
)
if not created:
raise FirstAdminProvisioningError(
"The enrollment email already belongs to an account."
)
membership = User(
tenant_id=tenant_id,
account_id=account.id,
email=account.email,
display_name=display_name or account.display_name,
is_active=True,
is_tenant_admin=True,
auth_provider=account.auth_provider,
password_hash=account.password_hash,
)
db.add(membership)
db.flush()
db.add(
UserRoleAssignment(
tenant_id=tenant_id,
user_id=membership.id,
role_id=tenant_roles["owner"].id,
)
)
db.add(
SystemRoleAssignment(
account_id=account.id,
role_id=system_roles["system_owner"].id,
)
)
db.flush()
return FirstSystemAdministratorRef(
account_id=account.id,
email=account.email,
display_name=account.display_name,
membership_id=membership.id,
tenant_id=tenant_id,
)
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise TypeError("Tenant access provisioner requires a SQLAlchemy Session")