feat: manage bounded automation service accounts

This commit is contained in:
2026-07-29 17:48:35 +02:00
parent 984a015704
commit 6b0dd8beab
10 changed files with 1633 additions and 113 deletions

View File

@@ -876,6 +876,55 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
secret: str
class ServiceAccountItem(BaseModel):
id: str
tenant_id: str
name: str
description: str | None = None
scope_ceiling: list[str] = Field(default_factory=list)
is_active: bool
revision: int
created_by_account_id: str | None = None
updated_by_account_id: str | None = None
retired_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ServiceAccountListResponse(BaseModel):
items: list[ServiceAccountItem]
class ServiceAccountCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
scope_ceiling: list[str] = Field(
default_factory=list,
max_length=200,
)
class ServiceAccountUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
scope_ceiling: list[str] | None = Field(
default=None,
max_length=200,
)
is_active: bool | None = None
class ServiceAccountRetireRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
class AuditAdminItem(BaseModel):
id: str
scope: Literal["tenant", "system"] = "tenant"

View File

@@ -0,0 +1,236 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
from govoplan_access.backend.api.v1.admin_schemas import (
ServiceAccountCreateRequest,
ServiceAccountItem,
ServiceAccountListResponse,
ServiceAccountRetireRequest,
ServiceAccountUpdateRequest,
)
from govoplan_access.backend.service_accounts import (
ServiceAccountConflictError,
ServiceAccountError,
ServiceAccountNotFoundError,
create_service_account,
get_service_account,
list_service_accounts,
retire_service_account,
update_service_account,
)
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
router = APIRouter(
prefix="/admin/service-accounts",
tags=["admin", "service-accounts"],
)
@router.get("", response_model=ServiceAccountListResponse)
def list_managed_service_accounts(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:read")
),
):
tenant = _resolve_tenant(session, principal, None)
return ServiceAccountListResponse(
items=[
_service_account_item(item)
for item in list_service_accounts(
session,
tenant_id=tenant.id,
)
]
)
@router.get(
"/{service_account_id}",
response_model=ServiceAccountItem,
)
def get_managed_service_account(
service_account_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:read")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = get_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
)
except ServiceAccountError as exc:
raise _service_account_http_error(exc) from exc
return _service_account_item(item)
@router.post(
"",
response_model=ServiceAccountItem,
status_code=status.HTTP_201_CREATED,
)
def create_managed_service_account(
payload: ServiceAccountCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = create_service_account(
session,
tenant=tenant,
principal=principal,
name=payload.name,
description=payload.description,
scope_ceiling=payload.scope_ceiling,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.created",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={
"name": item.name,
"scope_ceiling": list(item.scope_ceiling),
},
)
session.commit()
return _service_account_item(item)
@router.patch(
"/{service_account_id}",
response_model=ServiceAccountItem,
)
def update_managed_service_account(
service_account_id: str,
payload: ServiceAccountUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
changes = {
field: getattr(payload, field)
for field in payload.model_fields_set
if field != "expected_revision"
}
for field in ("name", "scope_ceiling", "is_active"):
if field in changes and changes[field] is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"{field} cannot be null",
)
try:
item = update_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
principal=principal,
expected_revision=payload.expected_revision,
changes=changes,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.updated",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={
"changed_fields": sorted(changes),
"revision": item.revision,
},
)
session.commit()
return _service_account_item(item)
@router.post(
"/{service_account_id}/retire",
response_model=ServiceAccountItem,
)
def retire_managed_service_account(
service_account_id: str,
payload: ServiceAccountRetireRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = retire_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_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.retired",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={"revision": item.revision},
)
session.commit()
return _service_account_item(item)
def _service_account_item(item: object) -> ServiceAccountItem:
return ServiceAccountItem.model_validate(
item,
from_attributes=True,
)
def _service_account_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, ServiceAccountNotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, ServiceAccountConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
if isinstance(exc, PermissionError):
return HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
__all__ = ["router"]