feat: manage bounded automation service accounts
This commit is contained in:
@@ -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"
|
||||
|
||||
236
src/govoplan_access/backend/api/v1/service_accounts.py
Normal file
236
src/govoplan_access/backend/api/v1/service_accounts.py
Normal 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"]
|
||||
Reference in New Issue
Block a user