Complete service-account credential administration

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent 1409dbf94d
commit 998d47ae94
15 changed files with 1672 additions and 17 deletions
@@ -887,6 +887,9 @@ class ServiceAccountItem(BaseModel):
created_by_account_id: str | None = None
updated_by_account_id: str | None = None
retired_at: datetime | None = None
credential_count: int = 0
active_credential_count: int = 0
last_credential_used_at: datetime | None = None
created_at: datetime
updated_at: datetime
@@ -925,6 +928,61 @@ class ServiceAccountRetireRequest(BaseModel):
expected_revision: int = Field(ge=1)
class ServiceAccountCredentialItem(BaseModel):
id: str
name: str
prefix: str
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime
class ServiceAccountCredentialListResponse(BaseModel):
service_account_revision: int
items: list[ServiceAccountCredentialItem]
class ServiceAccountCredentialCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
name: str = Field(min_length=1, max_length=255)
scopes: list[str] = Field(min_length=1, max_length=200)
expires_at: datetime | None = None
class ServiceAccountCredentialRotateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
name: str | None = Field(default=None, min_length=1, max_length=255)
scopes: list[str] | None = Field(
default=None,
min_length=1,
max_length=200,
)
expires_at: datetime | None = None
class ServiceAccountCredentialRevokeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
class ServiceAccountCredentialMutationResponse(BaseModel):
service_account_revision: int
credential: ServiceAccountCredentialItem
class ServiceAccountCredentialSecretResponse(
ServiceAccountCredentialMutationResponse
):
secret: str
class AuditAdminItem(BaseModel):
id: str
scope: Literal["tenant", "system"] = "tenant"
+42 -4
View File
@@ -3780,7 +3780,14 @@ def _api_key_items_for_response(session: Session, keys: list[ApiKey]) -> list[Ap
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse:
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
query = (
session.query(ApiKey)
.join(User, User.id == ApiKey.user_id)
.filter(
ApiKey.tenant_id == tenant.id,
User.auth_provider != "service_account",
)
)
if not include_revoked:
query = query.filter(ApiKey.revoked_at.is_(None))
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,))
@@ -3807,7 +3814,14 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke
if entries is None:
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
changed_ids = _changed_ids(entries, "access_api_key")
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
query = (
session.query(ApiKey)
.join(User, User.id == ApiKey.user_id)
.filter(
ApiKey.tenant_id == tenant.id,
User.auth_provider != "service_account",
)
)
if not include_revoked:
query = query.filter(ApiKey.revoked_at.is_(None))
visible = {
@@ -3857,7 +3871,14 @@ def list_api_keys(
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
query = (
session.query(ApiKey)
.join(User, User.id == ApiKey.user_id)
.filter(
ApiKey.tenant_id == tenant.id,
User.auth_provider != "service_account",
)
)
if not include_revoked:
query = query.filter(ApiKey.revoked_at.is_(None))
keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size)
@@ -3880,6 +3901,14 @@ def create_tenant_api_key(
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
if user.auth_provider == "service_account":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"Use the service-account credential API so revision, scope "
"ceiling, rotation, and audit guarantees remain enforced"
),
)
user_scopes = _user_item_for_response(
session,
user,
@@ -3930,7 +3959,16 @@ def revoke_api_key(
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
item = session.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.tenant_id == tenant.id).one_or_none()
item = (
session.query(ApiKey)
.join(User, User.id == ApiKey.user_id)
.filter(
ApiKey.id == api_key_id,
ApiKey.tenant_id == tenant.id,
User.auth_provider != "service_account",
)
.one_or_none()
)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
if item.revoked_at is None:
@@ -1,11 +1,19 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.governance import assert_api_keys_allowed
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
from govoplan_access.backend.api.v1.admin_schemas import (
ServiceAccountCreateRequest,
ServiceAccountCredentialCreateRequest,
ServiceAccountCredentialItem,
ServiceAccountCredentialListResponse,
ServiceAccountCredentialMutationResponse,
ServiceAccountCredentialRevokeRequest,
ServiceAccountCredentialRotateRequest,
ServiceAccountCredentialSecretResponse,
ServiceAccountItem,
ServiceAccountListResponse,
ServiceAccountRetireRequest,
@@ -13,14 +21,22 @@ from govoplan_access.backend.api.v1.admin_schemas import (
)
from govoplan_access.backend.service_accounts import (
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,
)
from govoplan_core.admin.common import AdminConflictError
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.db.session import get_session
@@ -40,13 +56,18 @@ def list_managed_service_accounts(
),
):
tenant = _resolve_tenant(session, principal, None)
items = list_service_accounts(
session,
tenant_id=tenant.id,
)
summaries = service_account_credential_summaries(
session,
service_accounts=items,
)
return ServiceAccountListResponse(
items=[
_service_account_item(item)
for item in list_service_accounts(
session,
tenant_id=tenant.id,
)
_service_account_item(item, summaries.get(item.id))
for item in items
]
)
@@ -71,7 +92,11 @@ def get_managed_service_account(
)
except ServiceAccountError as exc:
raise _service_account_http_error(exc) from exc
return _service_account_item(item)
summary = service_account_credential_summaries(
session,
service_accounts=(item,),
)[item.id]
return _service_account_item(item, summary)
@router.post(
@@ -204,8 +229,204 @@ def retire_managed_service_account(
return _service_account_item(item)
def _service_account_item(item: object) -> ServiceAccountItem:
return ServiceAccountItem.model_validate(
@router.get(
"/{service_account_id}/credentials",
response_model=ServiceAccountCredentialListResponse,
)
def list_managed_service_account_credentials(
service_account_id: str,
include_revoked: bool = Query(default=True),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:read")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item, credentials = list_service_account_credentials(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
include_revoked=include_revoked,
)
except ServiceAccountError as exc:
raise _service_account_http_error(exc) from exc
return ServiceAccountCredentialListResponse(
service_account_revision=item.revision,
items=[_credential_item(credential) for credential in credentials],
)
@router.post(
"/{service_account_id}/credentials",
response_model=ServiceAccountCredentialSecretResponse,
status_code=status.HTTP_201_CREATED,
)
def create_managed_service_account_credential(
service_account_id: str,
payload: ServiceAccountCredentialCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
assert_api_keys_allowed(session, tenant)
item, created = create_service_account_credential(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
principal=principal,
expected_revision=payload.expected_revision,
name=payload.name,
scopes=payload.scopes,
expires_at=payload.expires_at,
)
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.credential_created",
scope="tenant",
object_type="service_account_credential",
object_id=created.model.id,
details={
"service_account_id": item.id,
"prefix": created.model.prefix,
"scopes": list(created.model.scopes),
"service_account_revision": item.revision,
},
)
session.commit()
return ServiceAccountCredentialSecretResponse(
service_account_revision=item.revision,
credential=_credential_item(created.model),
secret=created.secret,
)
@router.post(
"/{service_account_id}/credentials/{credential_id}/rotate",
response_model=ServiceAccountCredentialSecretResponse,
)
def rotate_managed_service_account_credential(
service_account_id: str,
credential_id: str,
payload: ServiceAccountCredentialRotateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
assert_api_keys_allowed(session, tenant)
item, previous, created = rotate_service_account_credential(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
credential_id=credential_id,
principal=principal,
expected_revision=payload.expected_revision,
name=payload.name,
scopes=payload.scopes,
expires_at=payload.expires_at,
)
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.credential_rotated",
scope="tenant",
object_type="service_account_credential",
object_id=created.model.id,
details={
"service_account_id": item.id,
"previous_credential_id": previous.id,
"prefix": created.model.prefix,
"scopes": list(created.model.scopes),
"service_account_revision": item.revision,
},
)
session.commit()
return ServiceAccountCredentialSecretResponse(
service_account_revision=item.revision,
credential=_credential_item(created.model),
secret=created.secret,
)
@router.post(
"/{service_account_id}/credentials/{credential_id}/revoke",
response_model=ServiceAccountCredentialMutationResponse,
)
def revoke_managed_service_account_credential(
service_account_id: str,
credential_id: str,
payload: ServiceAccountCredentialRevokeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item, credential = revoke_service_account_credential(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
credential_id=credential_id,
principal=principal,
expected_revision=payload.expected_revision,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.credential_revoked",
scope="tenant",
object_type="service_account_credential",
object_id=credential.id,
details={
"service_account_id": item.id,
"prefix": credential.prefix,
"service_account_revision": item.revision,
},
)
session.commit()
return ServiceAccountCredentialMutationResponse(
service_account_revision=item.revision,
credential=_credential_item(credential),
)
def _service_account_item(
item: object,
summary: ServiceAccountCredentialSummary | None = None,
) -> ServiceAccountItem:
values = ServiceAccountItem.model_validate(
item, from_attributes=True
)
if summary is None:
return values
return values.model_copy(
update={
"credential_count": summary.credential_count,
"active_credential_count": summary.active_credential_count,
"last_credential_used_at": summary.last_credential_used_at,
}
)
def _credential_item(item: object) -> ServiceAccountCredentialItem:
return ServiceAccountCredentialItem.model_validate(
item,
from_attributes=True,
)
@@ -217,6 +438,11 @@ def _service_account_http_error(exc: Exception) -> HTTPException:
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, ServiceAccountCredentialNotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, ServiceAccountConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -227,6 +453,11 @@ def _service_account_http_error(exc: Exception) -> HTTPException:
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
if isinstance(exc, AdminConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
@@ -525,6 +525,18 @@ def _resolve_api_key_principal_context(
or user.tenant_id != api_key.tenant_id
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
if (
account.auth_provider == "service_account"
or user.auth_provider == "service_account"
):
return _resolve_service_account_credential_context(
session,
api_key=api_key,
account=account,
user=user,
tenant=tenant,
activity_touch_pending=activity_touch_pending,
)
idm_assignments, idm_roles = _principal_idm_context(
session,
user=user,
@@ -560,6 +572,61 @@ def _resolve_api_key_principal_context(
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
def _resolve_service_account_credential_context(
session: Session,
*,
api_key: ApiKey,
account: Account,
user: User,
tenant: Tenant,
activity_touch_pending: bool,
) -> ResolvedPrincipalContext:
item = (
session.query(ServiceAccount)
.filter(
ServiceAccount.tenant_id == tenant.id,
ServiceAccount.account_id == account.id,
ServiceAccount.membership_id == user.id,
)
.one_or_none()
)
if (
item is None
or account.auth_provider != "service_account"
or user.auth_provider != "service_account"
or not item.is_active
or item.retired_at is not None
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive or inconsistent service-account credential",
)
effective_scopes = intersect_api_key_scopes(
item.scope_ceiling,
api_key.scopes or [],
)
principal = PrincipalRef(
account_id=account.id,
membership_id=user.id,
tenant_id=tenant.id,
scopes=frozenset(effective_scopes),
auth_method="service_account",
api_key_id=api_key.id,
service_account_id=item.id,
email=None,
display_name=item.name,
)
if activity_touch_pending:
session.commit()
return ResolvedPrincipalContext(
principal=principal,
account=account,
user=user,
tenant=tenant,
api_key=api_key,
)
def _resolve_session_principal_ref(
request: Request,
session: Session,
+48 -1
View File
@@ -364,6 +364,8 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"),
DocumentationLink(label="Service accounts", href="/admin?section=tenant-service-accounts", kind="runtime"),
DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"),
),
configuration_keys=("access_governance",),
metadata={
@@ -375,6 +377,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"access.admin.tenant-groups",
"access.admin.tenant-roles",
"access.admin.api-keys",
"access.admin.service-accounts",
"access.credentials",
],
"route": "/admin",
@@ -443,6 +446,49 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
],
},
),
DocumentationTopic(
id="access.workflow.manage-service-account-credentials",
title="Manage service accounts and credentials",
summary="Create non-login automation principals, set a current scope ceiling, and rotate their one-time credentials without granting human login access.",
body=(
"Service accounts are tenant-owned automation principals. The account itself has no password or interactive session. Administrators first define its scope ceiling, then create one or more independently revocable credentials. "
"A credential secret is disclosed once and only its hash and prefix remain in GovOPlaN. Runtime authorization is always the intersection of the credential scopes and the service account's current ceiling, so lowering the ceiling or deactivating the account takes effect immediately. "
"Rotation creates the replacement and revokes the previous credential in one transaction. Retirement disables the backing principal and revokes every active credential. Every credential mutation requires the current service-account revision; a stale browser must reload instead of overwriting a concurrent change."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator"),
order=32,
conditions=(
DocumentationCondition(
required_modules=("access",),
any_scopes=(
"access:service_account:read",
"access:service_account:write",
),
),
),
links=(
DocumentationLink(label="Service accounts", href="/admin?section=tenant-service-accounts", kind="runtime"),
DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"),
DocumentationLink(label="Credential lifecycle API", href="/api/v1/admin/service-accounts/{service_account_id}/credentials", kind="api"),
),
metadata={
"kind": "workflow",
"help_contexts": ["access.admin.service-accounts"],
"prerequisites": [
"The tenant permits API credentials.",
"You have service-account write permission and may delegate every selected scope.",
],
"steps": [
"Create a service account and define the narrowest useful scope ceiling.",
"Open the account and create a credential with an equal or narrower scope grant.",
"Record the one-time secret in an external secret manager.",
"Rotate credentials before expiry and revoke credentials that are no longer used.",
],
"verification": "The administration table shows the expected active credential count, last-use timestamp, revision, and audit events without exposing secret material.",
},
),
DocumentationTopic(
id="access.reference.external-function-role-mappings",
title="Organization function facts and access roles",
@@ -457,7 +503,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator"),
order=32,
order=33,
conditions=(
DocumentationCondition(
required_modules=("access", "organizations"),
@@ -735,6 +781,7 @@ manifest = ModuleManifest(
ViewSurface(id="access.admin.tenant-users", module_id="access", kind="section", label="Tenant users", order=40),
ViewSurface(id="access.admin.tenant-credentials", module_id="access", kind="section", label="Tenant credentials", order=70),
ViewSurface(id="access.admin.tenant-api-keys", module_id="access", kind="section", label="Tenant API keys", order=80),
ViewSurface(id="access.admin.tenant-service-accounts", module_id="access", kind="section", label="Service accounts", order=90),
ViewSurface(id="access.admin.group-credentials", module_id="access", kind="section", label="Group credentials", order=30),
ViewSurface(id="access.admin.user-credentials", module_id="access", kind="section", label="User credentials", order=30),
ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30),
+360 -1
View File
@@ -1,6 +1,8 @@
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
@@ -9,12 +11,19 @@ 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):
@@ -29,6 +38,17 @@ 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,
*,
@@ -46,6 +66,195 @@ def list_service_accounts(
)
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,
*,
@@ -208,7 +417,7 @@ def retire_service_account(
principal: ApiPrincipal,
expected_revision: int,
) -> ServiceAccount:
return update_service_account(
item = update_service_account(
session,
tenant_id=tenant_id,
service_account_id=service_account_id,
@@ -216,6 +425,149 @@ def retire_service_account(
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:
@@ -273,11 +625,18 @@ def _service_account_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",
]