468 lines
14 KiB
Python
468 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
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,
|
|
ServiceAccountUpdateRequest,
|
|
)
|
|
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
|
|
|
|
|
|
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)
|
|
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, summaries.get(item.id))
|
|
for item in items
|
|
]
|
|
)
|
|
|
|
|
|
@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
|
|
summary = service_account_credential_summaries(
|
|
session,
|
|
service_accounts=(item,),
|
|
)[item.id]
|
|
return _service_account_item(item, summary)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@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,
|
|
)
|
|
|
|
|
|
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, 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,
|
|
detail=str(exc),
|
|
)
|
|
if isinstance(exc, PermissionError):
|
|
return 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),
|
|
)
|
|
|
|
|
|
__all__ = ["router"]
|