Complete service-account credential administration
This commit is contained in:
@@ -76,7 +76,7 @@ adds tenant administration plus tenant resolver behavior when installed.
|
|||||||
## Principal Context
|
## Principal Context
|
||||||
|
|
||||||
The stable principal DTO is `govoplan_core.core.access.PrincipalRef`. Access
|
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
|
serializes it as `principal` in auth API responses. Feature modules should use
|
||||||
that DTO, primitive IDs, or the core `govoplan_core.auth` dependency facade
|
that DTO, primitive IDs, or the core `govoplan_core.auth` dependency facade
|
||||||
instead of importing access ORM models or backend dependency internals.
|
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
|
authorization with the stored grant. Missing, inactive, moved, or
|
||||||
under-authorized owners fail closed before module work starts.
|
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
|
## WebUI Package
|
||||||
|
|
||||||
The repository root and `webui/` directory both expose the package
|
The repository root and `webui/` directory both expose the package
|
||||||
|
|||||||
@@ -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
|
created_by_account_id: str | None = None
|
||||||
updated_by_account_id: str | None = None
|
updated_by_account_id: str | None = None
|
||||||
retired_at: datetime | 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
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -925,6 +928,61 @@ class ServiceAccountRetireRequest(BaseModel):
|
|||||||
expected_revision: int = Field(ge=1)
|
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):
|
class AuditAdminItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
scope: Literal["tenant", "system"] = "tenant"
|
scope: Literal["tenant", "system"] = "tenant"
|
||||||
|
|||||||
@@ -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:
|
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:
|
if not include_revoked:
|
||||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
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,))
|
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:
|
if entries is None:
|
||||||
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
|
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
|
||||||
changed_ids = _changed_ids(entries, "access_api_key")
|
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:
|
if not include_revoked:
|
||||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||||
visible = {
|
visible = {
|
||||||
@@ -3857,7 +3871,14 @@ def list_api_keys(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
|
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
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:
|
if not include_revoked:
|
||||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
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)
|
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()
|
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:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
|
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(
|
user_scopes = _user_item_for_response(
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
@@ -3930,7 +3959,16 @@ def revoke_api_key(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
|
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
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:
|
if item is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
||||||
if item.revoked_at is None:
|
if item.revoked_at is None:
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
from __future__ import annotations
|
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 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_common import _resolve_tenant
|
||||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
ServiceAccountCreateRequest,
|
ServiceAccountCreateRequest,
|
||||||
|
ServiceAccountCredentialCreateRequest,
|
||||||
|
ServiceAccountCredentialItem,
|
||||||
|
ServiceAccountCredentialListResponse,
|
||||||
|
ServiceAccountCredentialMutationResponse,
|
||||||
|
ServiceAccountCredentialRevokeRequest,
|
||||||
|
ServiceAccountCredentialRotateRequest,
|
||||||
|
ServiceAccountCredentialSecretResponse,
|
||||||
ServiceAccountItem,
|
ServiceAccountItem,
|
||||||
ServiceAccountListResponse,
|
ServiceAccountListResponse,
|
||||||
ServiceAccountRetireRequest,
|
ServiceAccountRetireRequest,
|
||||||
@@ -13,14 +21,22 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
|||||||
)
|
)
|
||||||
from govoplan_access.backend.service_accounts import (
|
from govoplan_access.backend.service_accounts import (
|
||||||
ServiceAccountConflictError,
|
ServiceAccountConflictError,
|
||||||
|
ServiceAccountCredentialNotFoundError,
|
||||||
|
ServiceAccountCredentialSummary,
|
||||||
ServiceAccountError,
|
ServiceAccountError,
|
||||||
ServiceAccountNotFoundError,
|
ServiceAccountNotFoundError,
|
||||||
create_service_account,
|
create_service_account,
|
||||||
|
create_service_account_credential,
|
||||||
get_service_account,
|
get_service_account,
|
||||||
list_service_accounts,
|
list_service_accounts,
|
||||||
|
list_service_account_credentials,
|
||||||
|
revoke_service_account_credential,
|
||||||
retire_service_account,
|
retire_service_account,
|
||||||
|
rotate_service_account_credential,
|
||||||
|
service_account_credential_summaries,
|
||||||
update_service_account,
|
update_service_account,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.admin.common import AdminConflictError
|
||||||
from govoplan_core.audit.logging import audit_from_principal
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
@@ -40,13 +56,18 @@ def list_managed_service_accounts(
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, None)
|
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(
|
return ServiceAccountListResponse(
|
||||||
items=[
|
items=[
|
||||||
_service_account_item(item)
|
_service_account_item(item, summaries.get(item.id))
|
||||||
for item in list_service_accounts(
|
for item in items
|
||||||
session,
|
|
||||||
tenant_id=tenant.id,
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +92,11 @@ def get_managed_service_account(
|
|||||||
)
|
)
|
||||||
except ServiceAccountError as exc:
|
except ServiceAccountError as exc:
|
||||||
raise _service_account_http_error(exc) from 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(
|
@router.post(
|
||||||
@@ -204,8 +229,204 @@ def retire_managed_service_account(
|
|||||||
return _service_account_item(item)
|
return _service_account_item(item)
|
||||||
|
|
||||||
|
|
||||||
def _service_account_item(item: object) -> ServiceAccountItem:
|
@router.get(
|
||||||
return ServiceAccountItem.model_validate(
|
"/{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,
|
item,
|
||||||
from_attributes=True,
|
from_attributes=True,
|
||||||
)
|
)
|
||||||
@@ -217,6 +438,11 @@ def _service_account_http_error(exc: Exception) -> HTTPException:
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
)
|
)
|
||||||
|
if isinstance(exc, ServiceAccountCredentialNotFoundError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
if isinstance(exc, ServiceAccountConflictError):
|
if isinstance(exc, ServiceAccountConflictError):
|
||||||
return HTTPException(
|
return HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
@@ -227,6 +453,11 @@ def _service_account_http_error(exc: Exception) -> HTTPException:
|
|||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
)
|
)
|
||||||
|
if isinstance(exc, AdminConflictError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
return HTTPException(
|
return HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
|
|||||||
@@ -525,6 +525,18 @@ def _resolve_api_key_principal_context(
|
|||||||
or user.tenant_id != api_key.tenant_id
|
or user.tenant_id != api_key.tenant_id
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
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(
|
idm_assignments, idm_roles = _principal_idm_context(
|
||||||
session,
|
session,
|
||||||
user=user,
|
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)
|
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(
|
def _resolve_session_principal_ref(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
|||||||
@@ -364,6 +364,8 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
|||||||
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
|
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
|
||||||
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", 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="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",),
|
configuration_keys=("access_governance",),
|
||||||
metadata={
|
metadata={
|
||||||
@@ -375,6 +377,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
|||||||
"access.admin.tenant-groups",
|
"access.admin.tenant-groups",
|
||||||
"access.admin.tenant-roles",
|
"access.admin.tenant-roles",
|
||||||
"access.admin.api-keys",
|
"access.admin.api-keys",
|
||||||
|
"access.admin.service-accounts",
|
||||||
"access.credentials",
|
"access.credentials",
|
||||||
],
|
],
|
||||||
"route": "/admin",
|
"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(
|
DocumentationTopic(
|
||||||
id="access.reference.external-function-role-mappings",
|
id="access.reference.external-function-role-mappings",
|
||||||
title="Organization function facts and access roles",
|
title="Organization function facts and access roles",
|
||||||
@@ -457,7 +503,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
|||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "access_admin", "operator"),
|
audience=("tenant_admin", "access_admin", "operator"),
|
||||||
order=32,
|
order=33,
|
||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("access", "organizations"),
|
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-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-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-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.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.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),
|
ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
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.base import utcnow
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
Account,
|
Account,
|
||||||
|
ApiKey,
|
||||||
ServiceAccount,
|
ServiceAccount,
|
||||||
Tenant,
|
Tenant,
|
||||||
User,
|
User,
|
||||||
new_uuid,
|
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.auth import ApiPrincipal
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
|
||||||
|
|
||||||
class ServiceAccountError(ValueError):
|
class ServiceAccountError(ValueError):
|
||||||
@@ -29,6 +38,17 @@ class ServiceAccountConflictError(ServiceAccountError):
|
|||||||
pass
|
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(
|
def list_service_accounts(
|
||||||
session: Session,
|
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(
|
def get_service_account(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -208,7 +417,7 @@ def retire_service_account(
|
|||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
expected_revision: int,
|
expected_revision: int,
|
||||||
) -> ServiceAccount:
|
) -> ServiceAccount:
|
||||||
return update_service_account(
|
item = update_service_account(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
service_account_id=service_account_id,
|
service_account_id=service_account_id,
|
||||||
@@ -216,6 +425,149 @@ def retire_service_account(
|
|||||||
expected_revision=expected_revision,
|
expected_revision=expected_revision,
|
||||||
changes={"is_active": False},
|
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:
|
def _service_account_name(value: str) -> str:
|
||||||
@@ -273,11 +625,18 @@ def _service_account_scopes(
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ServiceAccountConflictError",
|
"ServiceAccountConflictError",
|
||||||
|
"ServiceAccountCredentialNotFoundError",
|
||||||
|
"ServiceAccountCredentialSummary",
|
||||||
"ServiceAccountError",
|
"ServiceAccountError",
|
||||||
"ServiceAccountNotFoundError",
|
"ServiceAccountNotFoundError",
|
||||||
"create_service_account",
|
"create_service_account",
|
||||||
|
"create_service_account_credential",
|
||||||
"get_service_account",
|
"get_service_account",
|
||||||
"list_service_accounts",
|
"list_service_accounts",
|
||||||
|
"list_service_account_credentials",
|
||||||
|
"revoke_service_account_credential",
|
||||||
"retire_service_account",
|
"retire_service_account",
|
||||||
|
"rotate_service_account_credential",
|
||||||
|
"service_account_credential_summaries",
|
||||||
"update_service_account",
|
"update_service_account",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -23,12 +23,16 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"access.admin.tenant-groups",
|
"access.admin.tenant-groups",
|
||||||
"access.admin.tenant-roles",
|
"access.admin.tenant-roles",
|
||||||
"access.admin.api-keys",
|
"access.admin.api-keys",
|
||||||
|
"access.admin.service-accounts",
|
||||||
"access.credentials",
|
"access.credentials",
|
||||||
},
|
},
|
||||||
"access.reference.external-function-role-mappings": {
|
"access.reference.external-function-role-mappings": {
|
||||||
"access.admin.function-mappings",
|
"access.admin.function-mappings",
|
||||||
"access.explanation",
|
"access.explanation",
|
||||||
},
|
},
|
||||||
|
"access.workflow.manage-service-account-credentials": {
|
||||||
|
"access.admin.service-accounts",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for topic_id, expected in expected_contexts.items():
|
for topic_id, expected in expected_contexts.items():
|
||||||
@@ -54,6 +58,7 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"access.admin.tenant-users",
|
"access.admin.tenant-users",
|
||||||
"access.admin.tenant-credentials",
|
"access.admin.tenant-credentials",
|
||||||
"access.admin.tenant-api-keys",
|
"access.admin.tenant-api-keys",
|
||||||
|
"access.admin.tenant-service-accounts",
|
||||||
"access.admin.group-credentials",
|
"access.admin.group-credentials",
|
||||||
"access.admin.user-credentials",
|
"access.admin.user-credentials",
|
||||||
"access.settings.credentials",
|
"access.settings.credentials",
|
||||||
|
|||||||
@@ -15,9 +15,16 @@ from govoplan_access.backend.db.models import (
|
|||||||
from govoplan_access.backend.service_accounts import (
|
from govoplan_access.backend.service_accounts import (
|
||||||
ServiceAccountConflictError,
|
ServiceAccountConflictError,
|
||||||
create_service_account,
|
create_service_account,
|
||||||
|
create_service_account_credential,
|
||||||
|
revoke_service_account_credential,
|
||||||
retire_service_account,
|
retire_service_account,
|
||||||
|
rotate_service_account_credential,
|
||||||
|
service_account_credential_summaries,
|
||||||
update_service_account,
|
update_service_account,
|
||||||
)
|
)
|
||||||
|
from govoplan_access.backend.auth.dependencies import (
|
||||||
|
_resolve_api_key_principal_context,
|
||||||
|
)
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
from govoplan_core.tenancy.scope import (
|
from govoplan_core.tenancy.scope import (
|
||||||
@@ -197,6 +204,129 @@ class ServiceAccountTests(unittest.TestCase):
|
|||||||
self.session.get(User, retired.membership_id).is_active
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ const roles = read("src/features/admin/RolesPanel.tsx");
|
|||||||
const systemUsers = read("src/features/admin/SystemUsersPanel.tsx");
|
const systemUsers = read("src/features/admin/SystemUsersPanel.tsx");
|
||||||
const systemRoles = read("src/features/admin/SystemRolesPanel.tsx");
|
const systemRoles = read("src/features/admin/SystemRolesPanel.tsx");
|
||||||
const apiKeys = read("src/features/admin/ApiKeysPanel.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 mappings = read("src/features/admin/ExternalFunctionRoleMappingsPanel.tsx");
|
||||||
const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx");
|
const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx");
|
||||||
const files = read("src/features/admin/FileConnectorsPanel.tsx");
|
const files = read("src/features/admin/FileConnectorsPanel.tsx");
|
||||||
const mail = read("src/features/admin/MailProfilesPanel.tsx");
|
const mail = read("src/features/admin/MailProfilesPanel.tsx");
|
||||||
const moduleSource = read("src/module.ts");
|
const moduleSource = read("src/module.ts");
|
||||||
const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings];
|
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, /TreeSubnav/);
|
||||||
assert.match(adminPage, /ActionBlockerHint/);
|
assert.match(adminPage, /ActionBlockerHint/);
|
||||||
@@ -41,6 +42,13 @@ assert.match(files, /usePlatformUiCapability<FilesConnectorsUiCapability>/);
|
|||||||
assert.match(files, /ActionBlockerHint/);
|
assert.match(files, /ActionBlockerHint/);
|
||||||
assert.match(mail, /usePlatformUiCapability<MailProfilesUiCapability>/);
|
assert.match(mail, /usePlatformUiCapability<MailProfilesUiCapability>/);
|
||||||
assert.match(mail, /ActionBlockerHint/);
|
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, /translations,/);
|
||||||
assert.match(moduleSource, /version: "0\.1\.11"/);
|
assert.match(moduleSource, /version: "0\.1\.11"/);
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,43 @@ export type ApiKeyAdminItem = {
|
|||||||
created_at: string;
|
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 = {
|
export type ExternalFunctionRoleMappingItem = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_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" });
|
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: {
|
export function createSystemAccount(settings: ApiSettings, payload: {
|
||||||
email: string;
|
email: string;
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import GroupsPanel from "./GroupsPanel";
|
|||||||
import RolesPanel from "./RolesPanel";
|
import RolesPanel from "./RolesPanel";
|
||||||
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
||||||
import ApiKeysPanel from "./ApiKeysPanel";
|
import ApiKeysPanel from "./ApiKeysPanel";
|
||||||
|
import ServiceAccountsPanel from "./ServiceAccountsPanel";
|
||||||
import FileConnectorsPanel from "./FileConnectorsPanel";
|
import FileConnectorsPanel from "./FileConnectorsPanel";
|
||||||
import MailProfilesPanel from "./MailProfilesPanel";
|
import MailProfilesPanel from "./MailProfilesPanel";
|
||||||
import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel";
|
import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel";
|
||||||
@@ -75,6 +76,7 @@ const handledAdminSectionIds = new Set<string>([
|
|||||||
"tenant-mail-servers",
|
"tenant-mail-servers",
|
||||||
"tenant-credentials",
|
"tenant-credentials",
|
||||||
"tenant-api-keys",
|
"tenant-api-keys",
|
||||||
|
"tenant-service-accounts",
|
||||||
"tenant-group-file-connectors",
|
"tenant-group-file-connectors",
|
||||||
"tenant-group-mail-servers",
|
"tenant-group-mail-servers",
|
||||||
"tenant-group-credentials",
|
"tenant-group-credentials",
|
||||||
@@ -93,6 +95,7 @@ const builtInAdminSurfaceIds: Record<string, string> = {
|
|||||||
"tenant-users": "access.admin.tenant-users",
|
"tenant-users": "access.admin.tenant-users",
|
||||||
"tenant-credentials": "access.admin.tenant-credentials",
|
"tenant-credentials": "access.admin.tenant-credentials",
|
||||||
"tenant-api-keys": "access.admin.tenant-api-keys",
|
"tenant-api-keys": "access.admin.tenant-api-keys",
|
||||||
|
"tenant-service-accounts": "access.admin.tenant-service-accounts",
|
||||||
"tenant-group-credentials": "access.admin.group-credentials",
|
"tenant-group-credentials": "access.admin.group-credentials",
|
||||||
"tenant-user-credentials": "access.admin.user-credentials",
|
"tenant-user-credentials": "access.admin.user-credentials",
|
||||||
"system-mail-servers": "mail.admin.system-servers",
|
"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 (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 (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, "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"])) {
|
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||||
sections.add("tenant-mail-servers");
|
sections.add("tenant-mail-servers");
|
||||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-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-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
|
||||||
visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70),
|
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-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80),
|
||||||
|
visibleNavItem(available, "tenant-service-accounts", "Service accounts", 90),
|
||||||
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
|
...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-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-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-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-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-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"])} />}
|
{!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";
|
||||||
|
}
|
||||||
@@ -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-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-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-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.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.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 }
|
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 }
|
||||||
|
|||||||
Reference in New Issue
Block a user