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"]