diff --git a/README.md b/README.md index 83216a6..50be762 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ adds tenant administration plus tenant resolver behavior when installed. ## Principal Context The stable principal DTO is `govoplan_core.core.access.PrincipalRef`. Access -resolves sessions, API keys, and future service accounts into that DTO and +resolves sessions, API keys, and service-account credentials into that DTO and serializes it as `principal` in auth API responses. Feature modules should use that DTO, primitive IDs, or the core `govoplan_core.auth` dependency facade instead of importing access ORM models or backend dependency internals. @@ -95,6 +95,15 @@ current roles, groups, functions, and delegations and intersects that authorization with the stored grant. Missing, inactive, moved, or under-authorized owners fail closed before module work starts. +Tenant administrators manage non-login service accounts under +`Admin > Tenant > Service accounts`. Each service account has a revisioned +scope ceiling and independently revocable API credentials. Credential secrets +are shown once; runtime authorization intersects the credential grant with the +current ceiling. Rotation creates a replacement and revokes the previous +credential atomically, while retirement revokes every active credential. See +[docs/SERVICE_ACCOUNTS.md](docs/SERVICE_ACCOUNTS.md) for the API and operational +contract. + ## WebUI Package The repository root and `webui/` directory both expose the package diff --git a/docs/SERVICE_ACCOUNTS.md b/docs/SERVICE_ACCOUNTS.md new file mode 100644 index 0000000..e8071a4 --- /dev/null +++ b/docs/SERVICE_ACCOUNTS.md @@ -0,0 +1,41 @@ +# Service accounts + +Service accounts are tenant-owned, non-login principals for automation. Their +backing account and membership cannot use a password or browser session. + +## Authorization model + +The service account defines a revisioned scope ceiling. Every credential has +its own narrower scope grant. On every authenticated request, Access checks +that the tenant, service account, backing account, membership, and credential +are active, then grants only the intersection of the current ceiling and the +credential scopes. Reducing the ceiling therefore takes effect without +reissuing a credential. + +Administrators may grant only scopes they currently hold. Credential creation +also follows the tenant API-key governance switch. Secrets are returned once; +the database stores a one-way hash and a non-authenticating prefix. + +## Administration + +Open `Admin > Tenant > Service accounts` to create, edit, deactivate, activate, +or retire a principal. The detail dialog lists active, expired, and revoked +credentials and exposes create, rotate, and revoke actions. + +Every write includes `expected_revision`. A concurrent change returns `409` +and the UI reloads the account before another action. Rotation creates the new +credential and revokes the old one in a single transaction. Retirement +deactivates the principal and revokes all active credentials. + +## API + +- `GET/POST /api/v1/admin/service-accounts` +- `GET/PATCH /api/v1/admin/service-accounts/{service_account_id}` +- `POST /api/v1/admin/service-accounts/{service_account_id}/retire` +- `GET/POST /api/v1/admin/service-accounts/{service_account_id}/credentials` +- `POST /api/v1/admin/service-accounts/{service_account_id}/credentials/{credential_id}/rotate` +- `POST /api/v1/admin/service-accounts/{service_account_id}/credentials/{credential_id}/revoke` + +Credential list responses never contain a secret. Create and rotate responses +contain it once. Audit records include identifiers, prefixes, scopes, and the +new service-account revision, but never the secret or its hash. diff --git a/src/govoplan_access/backend/api/v1/admin_schemas.py b/src/govoplan_access/backend/api/v1/admin_schemas.py index 16436e7..316135d 100644 --- a/src/govoplan_access/backend/api/v1/admin_schemas.py +++ b/src/govoplan_access/backend/api/v1/admin_schemas.py @@ -887,6 +887,9 @@ class ServiceAccountItem(BaseModel): created_by_account_id: str | None = None updated_by_account_id: str | None = None retired_at: datetime | None = None + credential_count: int = 0 + active_credential_count: int = 0 + last_credential_used_at: datetime | None = None created_at: datetime updated_at: datetime @@ -925,6 +928,61 @@ class ServiceAccountRetireRequest(BaseModel): expected_revision: int = Field(ge=1) +class ServiceAccountCredentialItem(BaseModel): + id: str + name: str + prefix: str + scopes: list[str] = Field(default_factory=list) + expires_at: datetime | None = None + last_used_at: datetime | None = None + revoked_at: datetime | None = None + created_at: datetime + + +class ServiceAccountCredentialListResponse(BaseModel): + service_account_revision: int + items: list[ServiceAccountCredentialItem] + + +class ServiceAccountCredentialCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + name: str = Field(min_length=1, max_length=255) + scopes: list[str] = Field(min_length=1, max_length=200) + expires_at: datetime | None = None + + +class ServiceAccountCredentialRotateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + name: str | None = Field(default=None, min_length=1, max_length=255) + scopes: list[str] | None = Field( + default=None, + min_length=1, + max_length=200, + ) + expires_at: datetime | None = None + + +class ServiceAccountCredentialRevokeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + + +class ServiceAccountCredentialMutationResponse(BaseModel): + service_account_revision: int + credential: ServiceAccountCredentialItem + + +class ServiceAccountCredentialSecretResponse( + ServiceAccountCredentialMutationResponse +): + secret: str + + class AuditAdminItem(BaseModel): id: str scope: Literal["tenant", "system"] = "tenant" diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index f3e26d9..5b63bcf 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -3780,7 +3780,14 @@ def _api_key_items_for_response(session: Session, keys: list[ApiKey]) -> list[Ap def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse: - query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) + query = ( + session.query(ApiKey) + .join(User, User.id == ApiKey.user_id) + .filter( + ApiKey.tenant_id == tenant.id, + User.auth_provider != "service_account", + ) + ) if not include_revoked: query = query.filter(ApiKey.revoked_at.is_(None)) snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,)) @@ -3807,7 +3814,14 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke if entries is None: return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit) changed_ids = _changed_ids(entries, "access_api_key") - query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) + query = ( + session.query(ApiKey) + .join(User, User.id == ApiKey.user_id) + .filter( + ApiKey.tenant_id == tenant.id, + User.auth_provider != "service_account", + ) + ) if not include_revoked: query = query.filter(ApiKey.revoked_at.is_(None)) visible = { @@ -3857,7 +3871,14 @@ def list_api_keys( principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) + query = ( + session.query(ApiKey) + .join(User, User.id == ApiKey.user_id) + .filter( + ApiKey.tenant_id == tenant.id, + User.auth_provider != "service_account", + ) + ) if not include_revoked: query = query.filter(ApiKey.revoked_at.is_(None)) keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size) @@ -3880,6 +3901,14 @@ def create_tenant_api_key( user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none() if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found") + if user.auth_provider == "service_account": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Use the service-account credential API so revision, scope " + "ceiling, rotation, and audit guarantees remain enforced" + ), + ) user_scopes = _user_item_for_response( session, user, @@ -3930,7 +3959,16 @@ def revoke_api_key( principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")), ): tenant = _resolve_tenant(session, principal, tenant_id) - item = session.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.tenant_id == tenant.id).one_or_none() + item = ( + session.query(ApiKey) + .join(User, User.id == ApiKey.user_id) + .filter( + ApiKey.id == api_key_id, + ApiKey.tenant_id == tenant.id, + User.auth_provider != "service_account", + ) + .one_or_none() + ) if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found") if item.revoked_at is None: diff --git a/src/govoplan_access/backend/api/v1/service_accounts.py b/src/govoplan_access/backend/api/v1/service_accounts.py index f980a8c..55ed9a8 100644 --- a/src/govoplan_access/backend/api/v1/service_accounts.py +++ b/src/govoplan_access/backend/api/v1/service_accounts.py @@ -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), diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index e429fb8..32e7523 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -525,6 +525,18 @@ def _resolve_api_key_principal_context( or user.tenant_id != api_key.tenant_id ): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") + if ( + account.auth_provider == "service_account" + or user.auth_provider == "service_account" + ): + return _resolve_service_account_credential_context( + session, + api_key=api_key, + account=account, + user=user, + tenant=tenant, + activity_touch_pending=activity_touch_pending, + ) idm_assignments, idm_roles = _principal_idm_context( session, user=user, @@ -560,6 +572,61 @@ def _resolve_api_key_principal_context( return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key) +def _resolve_service_account_credential_context( + session: Session, + *, + api_key: ApiKey, + account: Account, + user: User, + tenant: Tenant, + activity_touch_pending: bool, +) -> ResolvedPrincipalContext: + item = ( + session.query(ServiceAccount) + .filter( + ServiceAccount.tenant_id == tenant.id, + ServiceAccount.account_id == account.id, + ServiceAccount.membership_id == user.id, + ) + .one_or_none() + ) + if ( + item is None + or account.auth_provider != "service_account" + or user.auth_provider != "service_account" + or not item.is_active + or item.retired_at is not None + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive or inconsistent service-account credential", + ) + effective_scopes = intersect_api_key_scopes( + item.scope_ceiling, + api_key.scopes or [], + ) + principal = PrincipalRef( + account_id=account.id, + membership_id=user.id, + tenant_id=tenant.id, + scopes=frozenset(effective_scopes), + auth_method="service_account", + api_key_id=api_key.id, + service_account_id=item.id, + email=None, + display_name=item.name, + ) + if activity_touch_pending: + session.commit() + return ResolvedPrincipalContext( + principal=principal, + account=account, + user=user, + tenant=tenant, + api_key=api_key, + ) + + def _resolve_session_principal_ref( request: Request, session: Session, diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 0f73118..c9556cd 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -364,6 +364,8 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"), DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"), DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"), + DocumentationLink(label="Service accounts", href="/admin?section=tenant-service-accounts", kind="runtime"), + DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"), ), configuration_keys=("access_governance",), metadata={ @@ -375,6 +377,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( "access.admin.tenant-groups", "access.admin.tenant-roles", "access.admin.api-keys", + "access.admin.service-accounts", "access.credentials", ], "route": "/admin", @@ -443,6 +446,49 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( ], }, ), + DocumentationTopic( + id="access.workflow.manage-service-account-credentials", + title="Manage service accounts and credentials", + summary="Create non-login automation principals, set a current scope ceiling, and rotate their one-time credentials without granting human login access.", + body=( + "Service accounts are tenant-owned automation principals. The account itself has no password or interactive session. Administrators first define its scope ceiling, then create one or more independently revocable credentials. " + "A credential secret is disclosed once and only its hash and prefix remain in GovOPlaN. Runtime authorization is always the intersection of the credential scopes and the service account's current ceiling, so lowering the ceiling or deactivating the account takes effect immediately. " + "Rotation creates the replacement and revokes the previous credential in one transaction. Retirement disables the backing principal and revokes every active credential. Every credential mutation requires the current service-account revision; a stale browser must reload instead of overwriting a concurrent change." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "access_admin", "operator"), + order=32, + conditions=( + DocumentationCondition( + required_modules=("access",), + any_scopes=( + "access:service_account:read", + "access:service_account:write", + ), + ), + ), + links=( + DocumentationLink(label="Service accounts", href="/admin?section=tenant-service-accounts", kind="runtime"), + DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"), + DocumentationLink(label="Credential lifecycle API", href="/api/v1/admin/service-accounts/{service_account_id}/credentials", kind="api"), + ), + metadata={ + "kind": "workflow", + "help_contexts": ["access.admin.service-accounts"], + "prerequisites": [ + "The tenant permits API credentials.", + "You have service-account write permission and may delegate every selected scope.", + ], + "steps": [ + "Create a service account and define the narrowest useful scope ceiling.", + "Open the account and create a credential with an equal or narrower scope grant.", + "Record the one-time secret in an external secret manager.", + "Rotate credentials before expiry and revoke credentials that are no longer used.", + ], + "verification": "The administration table shows the expected active credential count, last-use timestamp, revision, and audit events without exposing secret material.", + }, + ), DocumentationTopic( id="access.reference.external-function-role-mappings", title="Organization function facts and access roles", @@ -457,7 +503,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( layer="configured", documentation_types=("admin", "user"), audience=("tenant_admin", "access_admin", "operator"), - order=32, + order=33, conditions=( DocumentationCondition( required_modules=("access", "organizations"), @@ -735,6 +781,7 @@ manifest = ModuleManifest( ViewSurface(id="access.admin.tenant-users", module_id="access", kind="section", label="Tenant users", order=40), ViewSurface(id="access.admin.tenant-credentials", module_id="access", kind="section", label="Tenant credentials", order=70), ViewSurface(id="access.admin.tenant-api-keys", module_id="access", kind="section", label="Tenant API keys", order=80), + ViewSurface(id="access.admin.tenant-service-accounts", module_id="access", kind="section", label="Service accounts", order=90), ViewSurface(id="access.admin.group-credentials", module_id="access", kind="section", label="Group credentials", order=30), ViewSurface(id="access.admin.user-credentials", module_id="access", kind="section", label="User credentials", order=30), ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30), diff --git a/src/govoplan_access/backend/service_accounts.py b/src/govoplan_access/backend/service_accounts.py index 5ba8ffd..042b00f 100644 --- a/src/govoplan_access/backend/service_accounts.py +++ b/src/govoplan_access/backend/service_accounts.py @@ -1,6 +1,8 @@ from __future__ import annotations from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from datetime import datetime from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -9,12 +11,19 @@ from sqlalchemy.orm import Session from govoplan_access.backend.db.base import utcnow from govoplan_access.backend.db.models import ( Account, + ApiKey, ServiceAccount, Tenant, User, new_uuid, ) +from govoplan_access.backend.permissions.catalog import scopes_grant +from govoplan_access.backend.security.api_keys import ( + CreatedApiKey, + create_api_key, +) from govoplan_core.auth import ApiPrincipal +from govoplan_core.security.time import ensure_aware_utc, utc_now class ServiceAccountError(ValueError): @@ -29,6 +38,17 @@ class ServiceAccountConflictError(ServiceAccountError): pass +class ServiceAccountCredentialNotFoundError(ServiceAccountError): + pass + + +@dataclass(frozen=True, slots=True) +class ServiceAccountCredentialSummary: + credential_count: int = 0 + active_credential_count: int = 0 + last_credential_used_at: datetime | None = None + + def list_service_accounts( session: Session, *, @@ -46,6 +66,195 @@ def list_service_accounts( ) +def service_account_credential_summaries( + session: Session, + *, + service_accounts: Iterable[ServiceAccount], +) -> dict[str, ServiceAccountCredentialSummary]: + items = tuple(service_accounts) + by_membership = {item.membership_id: item.id for item in items} + usable_accounts = { + item.id + for item in items + if item.is_active and item.retired_at is None + } + summaries = { + item.id: ServiceAccountCredentialSummary() + for item in items + } + if not by_membership: + return summaries + now = utc_now() + totals: dict[str, int] = {} + active: dict[str, int] = {} + last_used: dict[str, datetime | None] = {} + credentials = session.scalars( + select(ApiKey).where(ApiKey.user_id.in_(by_membership)) + ) + for credential in credentials: + service_account_id = by_membership[credential.user_id] + totals[service_account_id] = totals.get(service_account_id, 0) + 1 + expires_at = ensure_aware_utc(credential.expires_at) + if ( + service_account_id in usable_accounts + and + credential.revoked_at is None + and (expires_at is None or expires_at > now) + ): + active[service_account_id] = ( + active.get(service_account_id, 0) + 1 + ) + used_at = ensure_aware_utc(credential.last_used_at) + if used_at is not None and ( + last_used.get(service_account_id) is None + or used_at > last_used[service_account_id] + ): + last_used[service_account_id] = used_at + return { + item.id: ServiceAccountCredentialSummary( + credential_count=totals.get(item.id, 0), + active_credential_count=active.get(item.id, 0), + last_credential_used_at=last_used.get(item.id), + ) + for item in items + } + + +def list_service_account_credentials( + session: Session, + *, + tenant_id: str, + service_account_id: str, + include_revoked: bool = True, +) -> tuple[ServiceAccount, list[ApiKey]]: + item = get_service_account( + session, + tenant_id=tenant_id, + service_account_id=service_account_id, + ) + query = select(ApiKey).where( + ApiKey.tenant_id == tenant_id, + ApiKey.user_id == item.membership_id, + ) + if not include_revoked: + query = query.where(ApiKey.revoked_at.is_(None)) + credentials = list( + session.scalars( + query.order_by(ApiKey.created_at.desc(), ApiKey.id) + ) + ) + return item, credentials + + +def create_service_account_credential( + session: Session, + *, + tenant_id: str, + service_account_id: str, + principal: ApiPrincipal, + expected_revision: int, + name: str, + scopes: Iterable[str], + expires_at: datetime | None, +) -> tuple[ServiceAccount, CreatedApiKey]: + item = _locked_service_account_for_credential_change( + session, + tenant_id=tenant_id, + service_account_id=service_account_id, + expected_revision=expected_revision, + ) + user = _active_service_account_membership(session, item) + credential_scopes = _service_account_credential_scopes( + principal, + item, + scopes, + ) + created = create_api_key( + session, + user=user, + name=_credential_name(name), + scopes=list(credential_scopes), + expires_at=_future_expiry(expires_at), + ) + _touch_service_account(item, principal) + session.flush() + return item, created + + +def rotate_service_account_credential( + session: Session, + *, + tenant_id: str, + service_account_id: str, + credential_id: str, + principal: ApiPrincipal, + expected_revision: int, + name: str | None, + scopes: Iterable[str] | None, + expires_at: datetime | None, +) -> tuple[ServiceAccount, ApiKey, CreatedApiKey]: + item = _locked_service_account_for_credential_change( + session, + tenant_id=tenant_id, + service_account_id=service_account_id, + expected_revision=expected_revision, + ) + user = _active_service_account_membership(session, item) + previous = _locked_service_account_credential( + session, + item=item, + credential_id=credential_id, + ) + if previous.revoked_at is not None: + raise ServiceAccountConflictError( + "The credential is already revoked; reload before rotating" + ) + requested_scopes = previous.scopes if scopes is None else scopes + credential_scopes = _service_account_credential_scopes( + principal, + item, + requested_scopes, + ) + created = create_api_key( + session, + user=user, + name=_credential_name(name or previous.name), + scopes=list(credential_scopes), + expires_at=_future_expiry(expires_at), + ) + previous.revoked_at = utc_now() + _touch_service_account(item, principal) + session.flush() + return item, previous, created + + +def revoke_service_account_credential( + session: Session, + *, + tenant_id: str, + service_account_id: str, + credential_id: str, + principal: ApiPrincipal, + expected_revision: int, +) -> tuple[ServiceAccount, ApiKey]: + item = _locked_service_account_for_credential_change( + session, + tenant_id=tenant_id, + service_account_id=service_account_id, + expected_revision=expected_revision, + ) + credential = _locked_service_account_credential( + session, + item=item, + credential_id=credential_id, + ) + if credential.revoked_at is None: + credential.revoked_at = utc_now() + _touch_service_account(item, principal) + session.flush() + return item, credential + + def get_service_account( session: Session, *, @@ -208,7 +417,7 @@ def retire_service_account( principal: ApiPrincipal, expected_revision: int, ) -> ServiceAccount: - return update_service_account( + item = update_service_account( session, tenant_id=tenant_id, service_account_id=service_account_id, @@ -216,6 +425,149 @@ def retire_service_account( expected_revision=expected_revision, changes={"is_active": False}, ) + now = utc_now() + credentials = session.scalars( + select(ApiKey).where( + ApiKey.tenant_id == tenant_id, + ApiKey.user_id == item.membership_id, + ApiKey.revoked_at.is_(None), + ) + ) + for credential in credentials: + credential.revoked_at = now + session.flush() + return item + + +def _locked_service_account_for_credential_change( + session: Session, + *, + tenant_id: str, + service_account_id: str, + expected_revision: int, +) -> ServiceAccount: + item = get_service_account( + session, + tenant_id=tenant_id, + service_account_id=service_account_id, + lock=True, + ) + if item.revision != expected_revision: + raise ServiceAccountConflictError( + "Service account changed on the server; reload before changing credentials" + ) + return item + + +def _locked_service_account_credential( + session: Session, + *, + item: ServiceAccount, + credential_id: str, +) -> ApiKey: + credential = session.scalar( + select(ApiKey) + .where( + ApiKey.id == credential_id, + ApiKey.tenant_id == item.tenant_id, + ApiKey.user_id == item.membership_id, + ) + .with_for_update() + ) + if credential is None: + raise ServiceAccountCredentialNotFoundError( + "Service-account credential was not found" + ) + return credential + + +def _active_service_account_membership( + session: Session, + item: ServiceAccount, +) -> User: + user = session.get(User, item.membership_id) + account = session.get(Account, item.account_id) + if ( + not item.is_active + or item.retired_at is not None + or user is None + or account is None + or not user.is_active + or not account.is_active + ): + raise ServiceAccountConflictError( + "Activate the service account before creating or rotating credentials" + ) + return user + + +def _service_account_credential_scopes( + principal: ApiPrincipal, + item: ServiceAccount, + values: Iterable[str], +) -> tuple[str, ...]: + scopes = tuple( + sorted( + { + str(value).strip() + for value in values + if str(value).strip() + } + ) + ) + if not scopes: + raise ServiceAccountError( + "A service-account credential requires at least one scope" + ) + if len(scopes) > 200: + raise ServiceAccountError( + "Service-account credentials support at most 200 scopes" + ) + denied_by_ceiling = tuple( + scope + for scope in scopes + if not scopes_grant(item.scope_ceiling, scope) + ) + if denied_by_ceiling: + raise PermissionError( + "Credential scopes exceed the service-account scope ceiling: " + + ", ".join(denied_by_ceiling) + ) + denied_by_actor = tuple( + scope for scope in scopes if not principal.has(scope) + ) + if denied_by_actor: + raise PermissionError( + "Credential scopes exceed the current administrator authority: " + + ", ".join(denied_by_actor) + ) + return scopes + + +def _credential_name(value: str) -> str: + clean = " ".join(value.split()) + if not 1 <= len(clean) <= 255: + raise ServiceAccountError( + "Credential name must contain between 1 and 255 characters" + ) + return clean + + +def _future_expiry(value: datetime | None) -> datetime | None: + expires_at = ensure_aware_utc(value) + if expires_at is not None and expires_at <= utc_now(): + raise ServiceAccountError( + "Credential expiry must be in the future" + ) + return expires_at + + +def _touch_service_account( + item: ServiceAccount, + principal: ApiPrincipal, +) -> None: + item.revision += 1 + item.updated_by_account_id = principal.account_id def _service_account_name(value: str) -> str: @@ -273,11 +625,18 @@ def _service_account_scopes( __all__ = [ "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", ] diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index c23228f..4353099 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -23,12 +23,16 @@ class InterfaceDocumentationContractTests(unittest.TestCase): "access.admin.tenant-groups", "access.admin.tenant-roles", "access.admin.api-keys", + "access.admin.service-accounts", "access.credentials", }, "access.reference.external-function-role-mappings": { "access.admin.function-mappings", "access.explanation", }, + "access.workflow.manage-service-account-credentials": { + "access.admin.service-accounts", + }, } for topic_id, expected in expected_contexts.items(): @@ -54,6 +58,7 @@ class InterfaceDocumentationContractTests(unittest.TestCase): "access.admin.tenant-users", "access.admin.tenant-credentials", "access.admin.tenant-api-keys", + "access.admin.tenant-service-accounts", "access.admin.group-credentials", "access.admin.user-credentials", "access.settings.credentials", diff --git a/tests/test_service_accounts.py b/tests/test_service_accounts.py index dd2a9c8..34fea66 100644 --- a/tests/test_service_accounts.py +++ b/tests/test_service_accounts.py @@ -15,9 +15,16 @@ from govoplan_access.backend.db.models import ( from govoplan_access.backend.service_accounts import ( ServiceAccountConflictError, create_service_account, + create_service_account_credential, + revoke_service_account_credential, retire_service_account, + rotate_service_account_credential, + service_account_credential_summaries, update_service_account, ) +from govoplan_access.backend.auth.dependencies import ( + _resolve_api_key_principal_context, +) from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import PrincipalRef from govoplan_core.tenancy.scope import ( @@ -197,6 +204,129 @@ class ServiceAccountTests(unittest.TestCase): self.session.get(User, retired.membership_id).is_active ) + def test_credentials_are_one_time_scope_bounded_and_rotatable(self) -> None: + principal = self._principal() + item = create_service_account( + self.session, + tenant=self.tenant, + principal=principal, + name="Monthly worker", + description=None, + scope_ceiling=("dataflow:pipeline:run",), + ) + item, first = create_service_account_credential( + self.session, + tenant_id=self.tenant.id, + service_account_id=item.id, + principal=principal, + expected_revision=1, + name="Worker credential", + scopes=("dataflow:pipeline:run",), + expires_at=None, + ) + self.assertEqual(2, item.revision) + self.assertTrue(first.secret.startswith("mm_")) + self.assertNotEqual(first.secret, first.model.key_hash) + + item, previous, replacement = rotate_service_account_credential( + self.session, + tenant_id=self.tenant.id, + service_account_id=item.id, + credential_id=first.model.id, + principal=principal, + expected_revision=2, + name=None, + scopes=None, + expires_at=None, + ) + self.assertEqual(3, item.revision) + self.assertIsNotNone(previous.revoked_at) + self.assertIsNone(replacement.model.revoked_at) + self.assertNotEqual(first.secret, replacement.secret) + + with self.assertRaises(ServiceAccountConflictError): + revoke_service_account_credential( + self.session, + tenant_id=self.tenant.id, + service_account_id=item.id, + credential_id=replacement.model.id, + principal=principal, + expected_revision=2, + ) + + item, revoked = revoke_service_account_credential( + self.session, + tenant_id=self.tenant.id, + service_account_id=item.id, + credential_id=replacement.model.id, + principal=principal, + expected_revision=3, + ) + self.assertEqual(4, item.revision) + self.assertIsNotNone(revoked.revoked_at) + summary = service_account_credential_summaries( + self.session, + service_accounts=(item,), + )[item.id] + self.assertEqual(2, summary.credential_count) + self.assertEqual(0, summary.active_credential_count) + + def test_service_account_credential_uses_current_ceiling(self) -> None: + principal = self._principal() + item = create_service_account( + self.session, + tenant=self.tenant, + principal=principal, + name="Bounded API worker", + description=None, + scope_ceiling=("dataflow:pipeline:run",), + ) + item, created = create_service_account_credential( + self.session, + tenant_id=self.tenant.id, + service_account_id=item.id, + principal=principal, + expected_revision=1, + name="Runtime", + scopes=("dataflow:pipeline:run",), + expires_at=None, + ) + self.session.commit() + + context = _resolve_api_key_principal_context( + self.session, + token=created.secret, + idm_directory=None, + identity_directory=None, + organization_directory=None, + ) + self.assertIsNotNone(context) + self.assertEqual("service_account", context.principal.auth_method) + self.assertEqual(item.id, context.principal.service_account_id) + self.assertEqual(created.model.id, context.principal.api_key_id) + self.assertEqual( + frozenset({"dataflow:pipeline:run"}), + context.principal.scopes, + ) + + update_service_account( + self.session, + tenant_id=self.tenant.id, + service_account_id=item.id, + principal=principal, + expected_revision=2, + changes={"scope_ceiling": []}, + ) + self.session.commit() + narrowed = _resolve_api_key_principal_context( + self.session, + token=created.secret, + idm_directory=None, + identity_directory=None, + organization_directory=None, + ) + self.assertEqual(frozenset(), narrowed.principal.scopes) + if __name__ == "__main__": unittest.main() diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs index 5b60b6d..5a75a77 100644 --- a/webui/scripts/test-interface-pattern-language.mjs +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -12,13 +12,14 @@ const roles = read("src/features/admin/RolesPanel.tsx"); const systemUsers = read("src/features/admin/SystemUsersPanel.tsx"); const systemRoles = read("src/features/admin/SystemRolesPanel.tsx"); const apiKeys = read("src/features/admin/ApiKeysPanel.tsx"); +const serviceAccounts = read("src/features/admin/ServiceAccountsPanel.tsx"); const mappings = read("src/features/admin/ExternalFunctionRoleMappingsPanel.tsx"); const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx"); const files = read("src/features/admin/FileConnectorsPanel.tsx"); const mail = read("src/features/admin/MailProfilesPanel.tsx"); const moduleSource = read("src/module.ts"); const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings]; -const allAdminSource = [adminPage, credentials, files, mail, ...surfaces].join("\n"); +const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n"); assert.match(adminPage, /TreeSubnav/); assert.match(adminPage, /ActionBlockerHint/); @@ -41,6 +42,13 @@ assert.match(files, /usePlatformUiCapability/); assert.match(files, /ActionBlockerHint/); assert.match(mail, /usePlatformUiCapability/); assert.match(mail, /ActionBlockerHint/); +assert.match(serviceAccounts, /Service accounts/); +assert.match(serviceAccounts, /createServiceAccountCredential/); +assert.match(serviceAccounts, /rotateServiceAccountCredential/); +assert.match(serviceAccounts, /revokeServiceAccountCredential/); +assert.match(serviceAccounts, /Secrets are shown once/); +assert.match(serviceAccounts, / { + const response = await apiFetch<{ items: ServiceAccountItem[] }>(settings, "/api/v1/admin/service-accounts"); + return response.items; +} + +export function createServiceAccount(settings: ApiSettings, payload: { + name: string; + description?: string | null; + scope_ceiling: string[]; +}): Promise { + return apiFetch(settings, "/api/v1/admin/service-accounts", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateServiceAccount(settings: ApiSettings, serviceAccountId: string, payload: { + expected_revision: number; + name?: string; + description?: string | null; + scope_ceiling?: string[]; + is_active?: boolean; +}): Promise { + return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function retireServiceAccount(settings: ApiSettings, serviceAccountId: string, expectedRevision: number): Promise { + return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/retire`, { + method: "POST", + body: JSON.stringify({ expected_revision: expectedRevision }) + }); +} + +export function fetchServiceAccountCredentials(settings: ApiSettings, serviceAccountId: string, includeRevoked = true): Promise { + return apiFetch(settings, apiPath(`/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, { + include_revoked: includeRevoked + })); +} + +export function createServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, payload: { + expected_revision: number; + name: string; + scopes: string[]; + expires_at?: string | null; +}): Promise { + return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function rotateServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, payload: { + expected_revision: number; + name?: string | null; + scopes?: string[] | null; + expires_at?: string | null; +}): Promise { + return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/rotate`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function revokeServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, expectedRevision: number): Promise { + return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/revoke`, { + method: "POST", + body: JSON.stringify({ expected_revision: expectedRevision }) + }); +} + export function createSystemAccount(settings: ApiSettings, payload: { email: string; display_name?: string | null; diff --git a/webui/src/features/admin/AdminPage.tsx b/webui/src/features/admin/AdminPage.tsx index 0623099..bec8332 100644 --- a/webui/src/features/admin/AdminPage.tsx +++ b/webui/src/features/admin/AdminPage.tsx @@ -25,6 +25,7 @@ import GroupsPanel from "./GroupsPanel"; import RolesPanel from "./RolesPanel"; import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel"; import ApiKeysPanel from "./ApiKeysPanel"; +import ServiceAccountsPanel from "./ServiceAccountsPanel"; import FileConnectorsPanel from "./FileConnectorsPanel"; import MailProfilesPanel from "./MailProfilesPanel"; import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel"; @@ -75,6 +76,7 @@ const handledAdminSectionIds = new Set([ "tenant-mail-servers", "tenant-credentials", "tenant-api-keys", + "tenant-service-accounts", "tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-credentials", @@ -93,6 +95,7 @@ const builtInAdminSurfaceIds: Record = { "tenant-users": "access.admin.tenant-users", "tenant-credentials": "access.admin.tenant-credentials", "tenant-api-keys": "access.admin.tenant-api-keys", + "tenant-service-accounts": "access.admin.tenant-service-accounts", "tenant-group-credentials": "access.admin.group-credentials", "tenant-user-credentials": "access.admin.user-credentials", "system-mail-servers": "mail.admin.system-servers", @@ -179,6 +182,7 @@ export default function AdminPage({ if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles"); if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings"); if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys"); + if (hasScope(auth, "access:service_account:read")) sections.add("tenant-service-accounts"); if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) { sections.add("tenant-mail-servers"); if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers"); @@ -288,6 +292,7 @@ export default function AdminPage({ visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60), visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70), visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80), + visibleNavItem(available, "tenant-service-accounts", "Service accounts", 90), ...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds) ]) }, @@ -354,6 +359,7 @@ export default function AdminPage({ {!contributedSection && active === "tenant-roles" && } {!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && } {!contributedSection && active === "tenant-api-keys" && } + {!contributedSection && active === "tenant-service-accounts" && } {!contributedSection && active === "tenant-mail-servers" && } {!contributedSection && active === "tenant-credentials" && } {!contributedSection && active === "tenant-user-mail-servers" && } diff --git a/webui/src/features/admin/ServiceAccountsPanel.tsx b/webui/src/features/admin/ServiceAccountsPanel.tsx new file mode 100644 index 0000000..3cf11c6 --- /dev/null +++ b/webui/src/features/admin/ServiceAccountsPanel.tsx @@ -0,0 +1,545 @@ +import { useEffect, useMemo, useState } from "react"; +import { + KeyRound, + Pencil, + Plus, + RefreshCw, + Search, + ShieldOff, + Trash2 +} from "lucide-react"; +import { + AdminIconButton, + AdminPageLayout, + AdminSelectionList, + Button, + ConfirmDialog, + DataGrid, + DateTimeField, + Dialog, + DocumentationHelpLink, + FormField, + MetricCard, + StatusBadge, + TableActionGroup, + ToggleSwitch, + adminErrorMessage, + formatAdminDateTime as formatDateTime, + hasScope, + scopeGrants, + type ApiSettings, + type AuthInfo, + type DataGridColumn, + type PermissionItem +} from "@govoplan/core-webui"; +import { + createServiceAccount, + createServiceAccountCredential, + fetchPermissionCatalog, + fetchServiceAccountCredentials, + fetchServiceAccounts, + retireServiceAccount, + revokeServiceAccountCredential, + rotateServiceAccountCredential, + updateServiceAccount, + type ServiceAccountCredentialItem, + type ServiceAccountItem +} from "../../api/admin"; +import { + ACCESS_INTERFACE_I18N, + ACCESS_REFERENCE_DOCUMENTATION +} from "./interfacePatterns"; + +type AccountDraft = { + name: string; + description: string; + scopes: string[]; +}; + +type CredentialDraft = { + name: string; + scopes: string[]; + expiresAt: string; +}; + +type CredentialEditor = { + mode: "create" | "rotate"; + credential?: ServiceAccountCredentialItem; +}; + +export default function ServiceAccountsPanel({ + settings, + auth, + canWrite +}: { + settings: ApiSettings; + auth: AuthInfo; + canWrite: boolean; +}) { + const [accounts, setAccounts] = useState([]); + const [permissions, setPermissions] = useState([]); + const [managing, setManaging] = useState(null); + const [credentials, setCredentials] = useState([]); + const [showRevoked, setShowRevoked] = useState(true); + const [accountEditor, setAccountEditor] = useState<"create" | "edit" | null>(null); + const [accountDraft, setAccountDraft] = useState(emptyAccountDraft()); + const [credentialEditor, setCredentialEditor] = useState(null); + const [credentialDraft, setCredentialDraft] = useState(emptyCredentialDraft()); + const [secret, setSecret] = useState<{ name: string; value: string } | null>(null); + const [revoking, setRevoking] = useState(null); + const [retiring, setRetiring] = useState(false); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const grantablePermissions = useMemo( + () => permissions.filter((permission) => permission.level === "tenant" && hasScope(auth, permission.scope)), + [auth, permissions] + ); + const credentialPermissions = useMemo( + () => grantablePermissions.filter((permission) => managing?.scope_ceiling.some((scope) => scopeGrants(scope, permission.scope))), + [grantablePermissions, managing] + ); + + async function load() { + setLoading(true); + setError(""); + try { + const [nextAccounts, nextPermissions] = await Promise.all([ + fetchServiceAccounts(settings), + fetchPermissionCatalog(settings) + ]); + setAccounts(nextAccounts); + setPermissions(nextPermissions); + if (managing) { + const refreshed = nextAccounts.find((item) => item.id === managing.id) ?? null; + setManaging(refreshed); + } + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + } + + async function openManager(account: ServiceAccountItem) { + setManaging(account); + setCredentials([]); + setError(""); + try { + const response = await fetchServiceAccountCredentials(settings, account.id, true); + setCredentials(response.items); + setManaging({ ...account, revision: response.service_account_revision }); + } catch (err) { + setError(adminErrorMessage(err)); + } + } + + async function refreshManaged(serviceAccountId: string) { + const [nextAccounts, response] = await Promise.all([ + fetchServiceAccounts(settings), + fetchServiceAccountCredentials(settings, serviceAccountId, true) + ]); + const selected = nextAccounts.find((item) => item.id === serviceAccountId) ?? null; + setAccounts(nextAccounts); + setCredentials(response.items); + setManaging(selected ? { ...selected, revision: response.service_account_revision } : null); + } + + useEffect(() => { + void load(); + }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]); + + const accountColumns = useMemo[]>(() => [ + { + id: "name", + header: "Name", + width: "minmax(220px, 1fr)", + minWidth: 190, + resizable: true, + fill: true, + sticky: "start", + sortable: true, + filterable: true, + value: (row) => row.name, + render: (row) =>
{row.name}{row.description &&
{row.description}
}
+ }, + { + id: "status", + header: "Status", + width: 120, + resizable: false, + sortable: true, + filterable: true, + value: (row) => row.is_active ? "active" : "inactive", + render: (row) => + }, + { + id: "scope_ceiling", + header: "Scope ceiling", + width: 140, + resizable: false, + sortable: true, + filterable: true, + filterType: "integer", + value: (row) => row.scope_ceiling.length, + render: (row) => String(row.scope_ceiling.length) + }, + { + id: "credentials", + header: "Credentials", + width: 150, + resizable: false, + sortable: true, + value: (row) => row.active_credential_count, + render: (row) => `${row.active_credential_count} active / ${row.credential_count}` + }, + { + id: "last_used", + header: "Last used", + width: 180, + minWidth: 150, + resizable: true, + sortable: true, + value: (row) => row.last_credential_used_at || "", + render: (row) => formatDateTime(row.last_credential_used_at) + }, + { + id: "actions", + header: "Actions", + width: 108, + sticky: "end", + resizable: false, + align: "right", + render: (row) => , onClick: () => void openManager(row) } + ]} /> + } + ], []); + + const credentialColumns = useMemo[]>(() => [ + { + id: "name", + header: "Name", + width: "minmax(190px, 1fr)", + minWidth: 170, + fill: true, + resizable: true, + value: (row) => row.name, + render: (row) =>
{row.name}
{row.prefix}...
+ }, + { + id: "status", + header: "Status", + width: 120, + resizable: false, + value: credentialStatus, + render: (row) => + }, + { + id: "scopes", + header: "Scopes", + width: 100, + resizable: false, + value: (row) => row.scopes.length, + render: (row) => String(row.scopes.length) + }, + { + id: "last_used", + header: "Last used", + width: 170, + resizable: true, + value: (row) => row.last_used_at || "", + render: (row) => formatDateTime(row.last_used_at) + }, + { + id: "expires", + header: "Expires", + width: 170, + resizable: true, + value: (row) => row.expires_at || "", + render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry" + }, + { + id: "actions", + header: "Actions", + width: 108, + sticky: "end", + resizable: false, + align: "right", + render: (row) => , + applicable: !row.revoked_at, + disabled: !canWrite || !managing?.is_active, + disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing?.is_active ? "Activate the service account first." : undefined, + onClick: () => openCredentialEditor("rotate", row) + }, + { + id: "revoke", + label: `Revoke ${row.name}`, + icon: , + variant: "danger", + applicable: !row.revoked_at, + disabled: !canWrite, + disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, + onClick: () => setRevoking(row) + } + ]} /> + } + ], [canWrite, managing]); + + function openCreateAccount() { + setAccountDraft(emptyAccountDraft()); + setAccountEditor("create"); + } + + function openEditAccount() { + if (!managing) return; + setAccountDraft({ + name: managing.name, + description: managing.description ?? "", + scopes: [...managing.scope_ceiling] + }); + setAccountEditor("edit"); + } + + async function saveAccount() { + setBusy(true); + setError(""); + try { + if (accountEditor === "create") { + const created = await createServiceAccount(settings, { + name: accountDraft.name, + description: accountDraft.description || null, + scope_ceiling: accountDraft.scopes + }); + setSuccess(`Service account ${created.name} created.`); + } else if (managing) { + await updateServiceAccount(settings, managing.id, { + expected_revision: managing.revision, + name: accountDraft.name, + description: accountDraft.description || null, + scope_ceiling: accountDraft.scopes + }); + setSuccess(`Service account ${accountDraft.name} updated.`); + await refreshManaged(managing.id); + } + setAccountEditor(null); + if (accountEditor === "create") await load(); + } catch (err) { + setError(adminErrorMessage(err)); + if (managing) await refreshAfterConflict(managing.id); + } finally { + setBusy(false); + } + } + + async function setActive(active: boolean) { + if (!managing) return; + setBusy(true); + setError(""); + try { + await updateServiceAccount(settings, managing.id, { + expected_revision: managing.revision, + is_active: active + }); + setSuccess(`${managing.name} ${active ? "activated" : "deactivated"}.`); + await refreshManaged(managing.id); + } catch (err) { + setError(adminErrorMessage(err)); + await refreshAfterConflict(managing.id); + } finally { + setBusy(false); + } + } + + async function retire() { + if (!managing) return; + setBusy(true); + setError(""); + try { + await retireServiceAccount(settings, managing.id, managing.revision); + setSuccess(`${managing.name} retired and its credentials revoked.`); + setRetiring(false); + await refreshManaged(managing.id); + } catch (err) { + setError(adminErrorMessage(err)); + await refreshAfterConflict(managing.id); + } finally { + setBusy(false); + } + } + + function openCredentialEditor(mode: "create" | "rotate", credential?: ServiceAccountCredentialItem) { + setCredentialDraft(credential ? { + name: credential.name, + scopes: [...credential.scopes], + expiresAt: "" + } : emptyCredentialDraft()); + setCredentialEditor({ mode, credential }); + } + + async function saveCredential() { + if (!managing || !credentialEditor) return; + setBusy(true); + setError(""); + try { + const payload = { + expected_revision: managing.revision, + name: credentialDraft.name, + scopes: credentialDraft.scopes, + expires_at: credentialDraft.expiresAt ? new Date(credentialDraft.expiresAt).toISOString() : null + }; + const response = credentialEditor.mode === "create" + ? await createServiceAccountCredential(settings, managing.id, payload) + : await rotateServiceAccountCredential(settings, managing.id, credentialEditor.credential!.id, payload); + setSecret({ name: response.credential.name, value: response.secret }); + setSuccess(credentialEditor.mode === "create" ? "Credential created." : "Credential rotated; the previous credential is revoked."); + setCredentialEditor(null); + await refreshManaged(managing.id); + } catch (err) { + setError(adminErrorMessage(err)); + await refreshAfterConflict(managing.id); + } finally { + setBusy(false); + } + } + + async function revokeCredential() { + if (!managing || !revoking) return; + setBusy(true); + setError(""); + try { + await revokeServiceAccountCredential(settings, managing.id, revoking.id, managing.revision); + setSuccess(`Credential ${revoking.name} revoked.`); + setRevoking(null); + await refreshManaged(managing.id); + } catch (err) { + setError(adminErrorMessage(err)); + await refreshAfterConflict(managing.id); + } finally { + setBusy(false); + } + } + + async function refreshAfterConflict(serviceAccountId: string) { + try { + await refreshManaged(serviceAccountId); + } catch { + await load(); + } + } + + const visibleCredentials = showRevoked ? credentials : credentials.filter((item) => !item.revoked_at); + + return ( + <> + + + + } variant="primary" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} /> + } + > +
+ row.id} emptyText="No service accounts found." /> +
+
+ + !busy && setAccountEditor(null)} + className="admin-dialog admin-dialog-wide" + footer={<>} + > +
+ setAccountDraft({ ...accountDraft, name: event.target.value })} /> + setAccountDraft({ ...accountDraft, description: event.target.value })} /> +
+
+ Scope ceiling + ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={accountDraft.scopes} onChange={(scopes) => setAccountDraft({ ...accountDraft, scopes })} emptyText="No tenant scopes can be delegated by your current account." /> +
+
+ + !busy && setManaging(null)} + className="admin-dialog admin-dialog-wide" + footer={} + > + {managing && <> +
+ + + + +
+
+ + + + } variant="primary" onClick={() => openCredentialEditor("create")} disabled={!canWrite || !managing.is_active} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing.is_active ? "Activate the service account first." : undefined} /> +
+
+ +
+
+ row.id} emptyText="No credentials found." /> +
+

Secrets are shown once. Authentication always intersects a credential grant with this account's current scope ceiling, so reducing the ceiling takes effect immediately.

+ } +
+ + !busy && setCredentialEditor(null)} + className="admin-dialog admin-dialog-wide" + footer={<>} + > + {credentialEditor?.mode === "rotate" &&

Rotation creates a new secret and revokes the previous credential in the same transaction.

} +
+ setCredentialDraft({ ...credentialDraft, name: event.target.value })} /> + setCredentialDraft({ ...credentialDraft, expiresAt: value })} /> +
+
+ Credential scopes + ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={credentialDraft.scopes} onChange={(scopes) => setCredentialDraft({ ...credentialDraft, scopes })} emptyText="The service account has no credential scopes available." /> +
+
+ + setSecret(null)} className="admin-dialog" footer={}> + {secret && <>

The secret for {secret.name} is shown once.

{secret.value}

Store it in a secret manager. GovOPlaN retains only a one-way hash and the visible prefix.

} +
+ + setRevoking(null)} onConfirm={() => void revokeCredential()} /> + setRetiring(false)} onConfirm={() => void retire()} /> + + ); +} + +function emptyAccountDraft(): AccountDraft { + return { name: "", description: "", scopes: [] }; +} + +function emptyCredentialDraft(): CredentialDraft { + return { name: "", scopes: [], expiresAt: "" }; +} + +function credentialStatus(item: ServiceAccountCredentialItem): string { + if (item.revoked_at) return "revoked"; + if (item.expires_at && new Date(item.expires_at).getTime() <= Date.now()) return "expired"; + return "active"; +} diff --git a/webui/src/module.ts b/webui/src/module.ts index 9e2c29e..1c30d32 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -21,6 +21,7 @@ const accessAdminSurfaces = [ { id: "access.admin.tenant-users", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.tenant_users.cb800b38", order: 40 }, { id: "access.admin.tenant-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.tenant_credentials.4af2c024", order: 70 }, { id: "access.admin.tenant-api-keys", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.tenant_api_keys.4b1d81f8", order: 80 }, + { id: "access.admin.tenant-service-accounts", moduleId: "access", kind: "section" as const, label: "Service accounts", order: 90 }, { id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 }, { id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 }, { id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 }