Files
govoplan-access/src/govoplan_access/backend/service_accounts.py
T

643 lines
18 KiB
Python

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",
]