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
@@ -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),