Complete service-account credential administration

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent 1409dbf94d
commit 998d47ae94
15 changed files with 1672 additions and 17 deletions
+10 -1
View File
@@ -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
+41
View File
@@ -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.
@@ -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"
+42 -4
View File
@@ -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:
@@ -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),
@@ -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,
+48 -1
View File
@@ -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),
+360 -1
View File
@@ -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",
]
@@ -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",
+130
View File
@@ -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()
@@ -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<FilesConnectorsUiCapability>/);
assert.match(files, /ActionBlockerHint/);
assert.match(mail, /usePlatformUiCapability<MailProfilesUiCapability>/);
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, /<ConfirmDialog[\s\S]*Retire service account/);
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
assert.match(moduleSource, /translations,/);
assert.match(moduleSource, /version: "0\.1\.11"/);
+110
View File
@@ -204,6 +204,43 @@ export type ApiKeyAdminItem = {
created_at: string;
};
export type ServiceAccountItem = {
id: string;
tenant_id: string;
name: string;
description?: string | null;
scope_ceiling: string[];
is_active: boolean;
revision: number;
credential_count: number;
active_credential_count: number;
last_credential_used_at?: string | null;
retired_at?: string | null;
created_at: string;
updated_at: string;
};
export type ServiceAccountCredentialItem = {
id: string;
name: string;
prefix: string;
scopes: string[];
expires_at?: string | null;
last_used_at?: string | null;
revoked_at?: string | null;
created_at: string;
};
export type ServiceAccountCredentialListResponse = {
service_account_revision: number;
items: ServiceAccountCredentialItem[];
};
export type ServiceAccountCredentialMutationResponse = {
service_account_revision: number;
credential: ServiceAccountCredentialItem;
};
export type ExternalFunctionRoleMappingItem = {
id: string;
tenant_id: string;
@@ -447,6 +484,79 @@ export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiK
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
}
export async function fetchServiceAccounts(settings: ApiSettings): Promise<ServiceAccountItem[]> {
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<ServiceAccountItem> {
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<ServiceAccountItem> {
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<ServiceAccountItem> {
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<ServiceAccountCredentialListResponse> {
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<ServiceAccountCredentialMutationResponse & { secret: string }> {
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<ServiceAccountCredentialMutationResponse & { secret: string }> {
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<ServiceAccountCredentialMutationResponse> {
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;
+6
View File
@@ -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<string>([
"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<string, string> = {
"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" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
{!contributedSection && active === "tenant-service-accounts" && <ServiceAccountsPanel settings={settings} auth={auth} canWrite={hasScope(auth, "access:service_account:write")} />}
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="tenant" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
@@ -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<ServiceAccountItem[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const [managing, setManaging] = useState<ServiceAccountItem | null>(null);
const [credentials, setCredentials] = useState<ServiceAccountCredentialItem[]>([]);
const [showRevoked, setShowRevoked] = useState(true);
const [accountEditor, setAccountEditor] = useState<"create" | "edit" | null>(null);
const [accountDraft, setAccountDraft] = useState<AccountDraft>(emptyAccountDraft());
const [credentialEditor, setCredentialEditor] = useState<CredentialEditor | null>(null);
const [credentialDraft, setCredentialDraft] = useState<CredentialDraft>(emptyCredentialDraft());
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null);
const [revoking, setRevoking] = useState<ServiceAccountCredentialItem | null>(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<DataGridColumn<ServiceAccountItem>[]>(() => [
{
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) => <div><strong>{row.name}</strong>{row.description && <div className="muted small-note">{row.description}</div>}</div>
},
{
id: "status",
header: "Status",
width: 120,
resizable: false,
sortable: true,
filterable: true,
value: (row) => row.is_active ? "active" : "inactive",
render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} />
},
{
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) => <TableActionGroup actions={[
{ id: "manage", label: `Manage ${row.name}`, icon: <Search />, onClick: () => void openManager(row) }
]} />
}
], []);
const credentialColumns = useMemo<DataGridColumn<ServiceAccountCredentialItem>[]>(() => [
{
id: "name",
header: "Name",
width: "minmax(190px, 1fr)",
minWidth: 170,
fill: true,
resizable: true,
value: (row) => row.name,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}...</div></div>
},
{
id: "status",
header: "Status",
width: 120,
resizable: false,
value: credentialStatus,
render: (row) => <StatusBadge status={credentialStatus(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) => <TableActionGroup actions={[
{
id: "rotate",
label: `Rotate ${row.name}`,
icon: <RefreshCw />,
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: <Trash2 />,
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 (
<>
<AdminPageLayout
title="Service accounts"
description="Manage non-login automation principals and their independently rotatable, scope-bounded credentials."
loading={loading}
error={error}
success={success}
actions={<>
<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />
<Button onClick={() => void load()} disabled={loading}>Reload</Button>
<AdminIconButton label="Add service account" icon={<Plus />} variant="primary" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} />
</>}
>
<div className="admin-table-surface">
<DataGrid id="admin-service-accounts-v1" rows={accounts} columns={accountColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No service accounts found." />
</div>
</AdminPageLayout>
<Dialog
open={Boolean(accountEditor)}
title={accountEditor === "create" ? "Create service account" : "Edit service account"}
onClose={() => !busy && setAccountEditor(null)}
className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setAccountEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveAccount()} disabled={!canWrite || busy || !accountDraft.name.trim()}>{busy ? "Saving..." : "Save"}</Button></>}
>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={accountDraft.name} onChange={(event) => setAccountDraft({ ...accountDraft, name: event.target.value })} /></FormField>
<FormField label="Description"><input value={accountDraft.description} onChange={(event) => setAccountDraft({ ...accountDraft, description: event.target.value })} /></FormField>
</div>
<div className="form-field">
<span className="form-label">Scope ceiling</span>
<AdminSelectionList options={grantablePermissions.map((permission) => ({ 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." />
</div>
</Dialog>
<Dialog
open={Boolean(managing)}
title={managing?.name ?? "Service account"}
onClose={() => !busy && setManaging(null)}
className="admin-dialog admin-dialog-wide"
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
>
{managing && <>
<div className="metric-grid compact">
<MetricCard label="Status" value={managing.is_active ? "Active" : "Inactive"} tone={managing.is_active ? "good" : "warning"} />
<MetricCard label="Active credentials" value={managing.active_credential_count} />
<MetricCard label="Scope ceiling" value={managing.scope_ceiling.length} />
<MetricCard label="Revision" value={managing.revision} />
</div>
<div className="admin-toolbar-row">
<Button onClick={openEditAccount} disabled={!canWrite || busy}><Pencil aria-hidden="true" /> Edit</Button>
<Button onClick={() => void setActive(!managing.is_active)} disabled={!canWrite || busy}>{managing.is_active ? <ShieldOff aria-hidden="true" /> : <RefreshCw aria-hidden="true" />} {managing.is_active ? "Deactivate" : "Activate"}</Button>
<Button variant="danger" onClick={() => setRetiring(true)} disabled={!canWrite || busy || !managing.is_active}><Trash2 aria-hidden="true" /> Retire</Button>
<AdminIconButton label="Create credential" icon={<KeyRound />} 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} />
</div>
<div className="admin-toolbar-row">
<ToggleSwitch label="Show revoked credentials" checked={showRevoked} onChange={setShowRevoked} />
</div>
<div className="admin-table-surface">
<DataGrid id="admin-service-account-credentials-v1" rows={visibleCredentials} columns={credentialColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No credentials found." />
</div>
<p className="muted small-note">Secrets are shown once. Authentication always intersects a credential grant with this account's current scope ceiling, so reducing the ceiling takes effect immediately.</p>
</>}
</Dialog>
<Dialog
open={Boolean(credentialEditor)}
title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"}
onClose={() => !busy && setCredentialEditor(null)}
className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setCredentialEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveCredential()} disabled={!canWrite || busy || !credentialDraft.name.trim() || credentialDraft.scopes.length === 0}>{busy ? "Saving..." : credentialEditor?.mode === "rotate" ? "Rotate" : "Create"}</Button></>}
>
{credentialEditor?.mode === "rotate" && <p className="muted small-note">Rotation creates a new secret and revokes the previous credential in the same transaction.</p>}
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={credentialDraft.name} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} /></FormField>
<FormField label="Expiry"><DateTimeField value={credentialDraft.expiresAt} onChange={(value) => setCredentialDraft({ ...credentialDraft, expiresAt: value })} /></FormField>
</div>
<div className="form-field">
<span className="form-label">Credential scopes</span>
<AdminSelectionList options={credentialPermissions.map((permission) => ({ 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." />
</div>
</Dialog>
<Dialog open={Boolean(secret)} title="Service-account secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}>
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. GovOPlaN retains only a one-way hash and the visible prefix.</p></>}
</Dialog>
<ConfirmDialog open={Boolean(revoking)} title="Revoke credential" message={`Revoke ${revoking?.name ?? "this credential"}? Existing clients using it will immediately lose access.`} confirmLabel="Revoke credential" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revokeCredential()} />
<ConfirmDialog open={retiring} title="Retire service account" message={`Retire ${managing?.name ?? "this service account"} and revoke all ${managing?.active_credential_count ?? 0} active credentials?`} confirmLabel="Retire and revoke" tone="danger" busy={busy} onCancel={() => 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";
}
+1
View File
@@ -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 }