643 lines
18 KiB
Python
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",
|
|
]
|