from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass from datetime import datetime from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_access.backend.db.base import utcnow from govoplan_access.backend.db.models import ( Account, ApiKey, ServiceAccount, Tenant, User, new_uuid, ) from govoplan_access.backend.permissions.catalog import scopes_grant from govoplan_access.backend.security.api_keys import ( CreatedApiKey, create_api_key, ) from govoplan_core.auth import ApiPrincipal from govoplan_core.security.time import ensure_aware_utc, utc_now class ServiceAccountError(ValueError): pass class ServiceAccountNotFoundError(ServiceAccountError): pass class ServiceAccountConflictError(ServiceAccountError): pass class ServiceAccountCredentialNotFoundError(ServiceAccountError): pass @dataclass(frozen=True, slots=True) class ServiceAccountCredentialSummary: credential_count: int = 0 active_credential_count: int = 0 last_credential_used_at: datetime | None = None def list_service_accounts( session: Session, *, tenant_id: str, ) -> list[ServiceAccount]: return list( session.scalars( select(ServiceAccount) .where(ServiceAccount.tenant_id == tenant_id) .order_by( ServiceAccount.normalized_name, ServiceAccount.id, ) ) ) def service_account_credential_summaries( session: Session, *, service_accounts: Iterable[ServiceAccount], ) -> dict[str, ServiceAccountCredentialSummary]: items = tuple(service_accounts) by_membership = {item.membership_id: item.id for item in items} usable_accounts = { item.id for item in items if item.is_active and item.retired_at is None } summaries = { item.id: ServiceAccountCredentialSummary() for item in items } if not by_membership: return summaries now = utc_now() totals: dict[str, int] = {} active: dict[str, int] = {} last_used: dict[str, datetime | None] = {} credentials = session.scalars( select(ApiKey).where(ApiKey.user_id.in_(by_membership)) ) for credential in credentials: service_account_id = by_membership[credential.user_id] totals[service_account_id] = totals.get(service_account_id, 0) + 1 expires_at = ensure_aware_utc(credential.expires_at) if ( service_account_id in usable_accounts and credential.revoked_at is None and (expires_at is None or expires_at > now) ): active[service_account_id] = ( active.get(service_account_id, 0) + 1 ) used_at = ensure_aware_utc(credential.last_used_at) if used_at is not None and ( last_used.get(service_account_id) is None or used_at > last_used[service_account_id] ): last_used[service_account_id] = used_at return { item.id: ServiceAccountCredentialSummary( credential_count=totals.get(item.id, 0), active_credential_count=active.get(item.id, 0), last_credential_used_at=last_used.get(item.id), ) for item in items } def list_service_account_credentials( session: Session, *, tenant_id: str, service_account_id: str, include_revoked: bool = True, ) -> tuple[ServiceAccount, list[ApiKey]]: item = get_service_account( session, tenant_id=tenant_id, service_account_id=service_account_id, ) query = select(ApiKey).where( ApiKey.tenant_id == tenant_id, ApiKey.user_id == item.membership_id, ) if not include_revoked: query = query.where(ApiKey.revoked_at.is_(None)) credentials = list( session.scalars( query.order_by(ApiKey.created_at.desc(), ApiKey.id) ) ) return item, credentials def create_service_account_credential( session: Session, *, tenant_id: str, service_account_id: str, principal: ApiPrincipal, expected_revision: int, name: str, scopes: Iterable[str], expires_at: datetime | None, ) -> tuple[ServiceAccount, CreatedApiKey]: item = _locked_service_account_for_credential_change( session, tenant_id=tenant_id, service_account_id=service_account_id, expected_revision=expected_revision, ) user = _active_service_account_membership(session, item) credential_scopes = _service_account_credential_scopes( principal, item, scopes, ) created = create_api_key( session, user=user, name=_credential_name(name), scopes=list(credential_scopes), expires_at=_future_expiry(expires_at), ) _touch_service_account(item, principal) session.flush() return item, created def rotate_service_account_credential( session: Session, *, tenant_id: str, service_account_id: str, credential_id: str, principal: ApiPrincipal, expected_revision: int, name: str | None, scopes: Iterable[str] | None, expires_at: datetime | None, ) -> tuple[ServiceAccount, ApiKey, CreatedApiKey]: item = _locked_service_account_for_credential_change( session, tenant_id=tenant_id, service_account_id=service_account_id, expected_revision=expected_revision, ) user = _active_service_account_membership(session, item) previous = _locked_service_account_credential( session, item=item, credential_id=credential_id, ) if previous.revoked_at is not None: raise ServiceAccountConflictError( "The credential is already revoked; reload before rotating" ) requested_scopes = previous.scopes if scopes is None else scopes credential_scopes = _service_account_credential_scopes( principal, item, requested_scopes, ) created = create_api_key( session, user=user, name=_credential_name(name or previous.name), scopes=list(credential_scopes), expires_at=_future_expiry(expires_at), ) previous.revoked_at = utc_now() _touch_service_account(item, principal) session.flush() return item, previous, created def revoke_service_account_credential( session: Session, *, tenant_id: str, service_account_id: str, credential_id: str, principal: ApiPrincipal, expected_revision: int, ) -> tuple[ServiceAccount, ApiKey]: item = _locked_service_account_for_credential_change( session, tenant_id=tenant_id, service_account_id=service_account_id, expected_revision=expected_revision, ) credential = _locked_service_account_credential( session, item=item, credential_id=credential_id, ) if credential.revoked_at is None: credential.revoked_at = utc_now() _touch_service_account(item, principal) session.flush() return item, credential def get_service_account( session: Session, *, tenant_id: str, service_account_id: str, lock: bool = False, ) -> ServiceAccount: query = select(ServiceAccount).where( ServiceAccount.id == service_account_id, ServiceAccount.tenant_id == tenant_id, ) if lock: query = query.with_for_update() item = session.scalar(query) if item is None: raise ServiceAccountNotFoundError( "Service account was not found" ) return item def create_service_account( session: Session, *, tenant: Tenant, principal: ApiPrincipal, name: str, description: str | None, scope_ceiling: Iterable[str], ) -> ServiceAccount: clean_name = _service_account_name(name) scopes = _service_account_scopes( principal, scope_ceiling, ) service_account_id = new_uuid() internal_email = ( f"service-account-{service_account_id}@govoplan.invalid" ) account = Account( id=new_uuid(), email=internal_email, normalized_email=internal_email, display_name=clean_name, is_active=True, auth_provider="service_account", password_hash=None, password_reset_required=False, ) membership = User( id=new_uuid(), tenant_id=tenant.id, account=account, email=internal_email, display_name=clean_name, is_active=True, is_tenant_admin=False, auth_provider="service_account", password_hash=None, settings={"managed_service_account": service_account_id}, ) item = ServiceAccount( id=service_account_id, tenant_id=tenant.id, account_id=account.id, membership_id=membership.id, name=clean_name, normalized_name=_normalized_name(clean_name), description=_optional_text(description), scope_ceiling=list(scopes), is_active=True, revision=1, created_by_account_id=principal.account_id, updated_by_account_id=principal.account_id, settings={}, ) session.add_all((account, membership, item)) try: session.flush() except IntegrityError as exc: raise ServiceAccountConflictError( "A service account with this name already exists" ) from exc return item def update_service_account( session: Session, *, tenant_id: str, service_account_id: str, principal: ApiPrincipal, expected_revision: int, changes: Mapping[str, object], ) -> ServiceAccount: item = get_service_account( session, tenant_id=tenant_id, service_account_id=service_account_id, lock=True, ) if item.revision != expected_revision: raise ServiceAccountConflictError( "Service account changed on the server; reload before saving" ) account = session.get(Account, item.account_id) membership = session.get(User, item.membership_id) if account is None or membership is None: raise ServiceAccountConflictError( "Service account backing identity is missing" ) if "name" in changes: clean_name = _service_account_name(str(changes["name"])) item.name = clean_name item.normalized_name = _normalized_name(clean_name) account.display_name = clean_name membership.display_name = clean_name if "description" in changes: value = changes["description"] item.description = _optional_text( str(value) if value is not None else None ) if "scope_ceiling" in changes: raw_scopes = changes["scope_ceiling"] if not isinstance(raw_scopes, Iterable) or isinstance( raw_scopes, (str, bytes), ): raise ServiceAccountError( "Service account scope ceiling is invalid" ) item.scope_ceiling = list( _service_account_scopes( principal, (str(scope) for scope in raw_scopes), ) ) if "is_active" in changes: active = bool(changes["is_active"]) item.is_active = active account.is_active = active membership.is_active = active item.retired_at = None if active else utcnow() item.revision += 1 item.updated_by_account_id = principal.account_id try: session.flush() except IntegrityError as exc: raise ServiceAccountConflictError( "A service account with this name already exists" ) from exc return item def retire_service_account( session: Session, *, tenant_id: str, service_account_id: str, principal: ApiPrincipal, expected_revision: int, ) -> ServiceAccount: item = update_service_account( session, tenant_id=tenant_id, service_account_id=service_account_id, principal=principal, expected_revision=expected_revision, changes={"is_active": False}, ) now = utc_now() credentials = session.scalars( select(ApiKey).where( ApiKey.tenant_id == tenant_id, ApiKey.user_id == item.membership_id, ApiKey.revoked_at.is_(None), ) ) for credential in credentials: credential.revoked_at = now session.flush() return item def _locked_service_account_for_credential_change( session: Session, *, tenant_id: str, service_account_id: str, expected_revision: int, ) -> ServiceAccount: item = get_service_account( session, tenant_id=tenant_id, service_account_id=service_account_id, lock=True, ) if item.revision != expected_revision: raise ServiceAccountConflictError( "Service account changed on the server; reload before changing credentials" ) return item def _locked_service_account_credential( session: Session, *, item: ServiceAccount, credential_id: str, ) -> ApiKey: credential = session.scalar( select(ApiKey) .where( ApiKey.id == credential_id, ApiKey.tenant_id == item.tenant_id, ApiKey.user_id == item.membership_id, ) .with_for_update() ) if credential is None: raise ServiceAccountCredentialNotFoundError( "Service-account credential was not found" ) return credential def _active_service_account_membership( session: Session, item: ServiceAccount, ) -> User: user = session.get(User, item.membership_id) account = session.get(Account, item.account_id) if ( not item.is_active or item.retired_at is not None or user is None or account is None or not user.is_active or not account.is_active ): raise ServiceAccountConflictError( "Activate the service account before creating or rotating credentials" ) return user def _service_account_credential_scopes( principal: ApiPrincipal, item: ServiceAccount, values: Iterable[str], ) -> tuple[str, ...]: scopes = tuple( sorted( { str(value).strip() for value in values if str(value).strip() } ) ) if not scopes: raise ServiceAccountError( "A service-account credential requires at least one scope" ) if len(scopes) > 200: raise ServiceAccountError( "Service-account credentials support at most 200 scopes" ) denied_by_ceiling = tuple( scope for scope in scopes if not scopes_grant(item.scope_ceiling, scope) ) if denied_by_ceiling: raise PermissionError( "Credential scopes exceed the service-account scope ceiling: " + ", ".join(denied_by_ceiling) ) denied_by_actor = tuple( scope for scope in scopes if not principal.has(scope) ) if denied_by_actor: raise PermissionError( "Credential scopes exceed the current administrator authority: " + ", ".join(denied_by_actor) ) return scopes def _credential_name(value: str) -> str: clean = " ".join(value.split()) if not 1 <= len(clean) <= 255: raise ServiceAccountError( "Credential name must contain between 1 and 255 characters" ) return clean def _future_expiry(value: datetime | None) -> datetime | None: expires_at = ensure_aware_utc(value) if expires_at is not None and expires_at <= utc_now(): raise ServiceAccountError( "Credential expiry must be in the future" ) return expires_at def _touch_service_account( item: ServiceAccount, principal: ApiPrincipal, ) -> None: item.revision += 1 item.updated_by_account_id = principal.account_id def _service_account_name(value: str) -> str: clean = " ".join(value.split()) if not 1 <= len(clean) <= 255: raise ServiceAccountError( "Service account name must contain between 1 and 255 characters" ) return clean def _normalized_name(value: str) -> str: return value.casefold() def _optional_text(value: str | None) -> str | None: if value is None: return None clean = value.strip() if len(clean) > 4000: raise ServiceAccountError( "Service account description is too long" ) return clean or None def _service_account_scopes( principal: ApiPrincipal, values: Iterable[str], ) -> tuple[str, ...]: scopes = tuple( sorted( { str(value).strip() for value in values if str(value).strip() } ) ) if len(scopes) > 200: raise ServiceAccountError( "Service accounts support at most 200 scope grants" ) denied = tuple( scope for scope in scopes if not principal.has(scope) ) if denied: raise PermissionError( "Cannot grant service-account scopes outside the current " f"administrator authority: {', '.join(denied)}" ) return scopes __all__ = [ "ServiceAccountConflictError", "ServiceAccountCredentialNotFoundError", "ServiceAccountCredentialSummary", "ServiceAccountError", "ServiceAccountNotFoundError", "create_service_account", "create_service_account_credential", "get_service_account", "list_service_accounts", "list_service_account_credentials", "revoke_service_account_credential", "retire_service_account", "rotate_service_account_credential", "service_account_credential_summaries", "update_service_account", ]