Complete service-account credential administration
This commit is contained in:
@@ -887,6 +887,9 @@ class ServiceAccountItem(BaseModel):
|
||||
created_by_account_id: str | None = None
|
||||
updated_by_account_id: str | None = None
|
||||
retired_at: datetime | None = None
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -925,6 +928,61 @@ class ServiceAccountRetireRequest(BaseModel):
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ServiceAccountCredentialItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ServiceAccountCredentialListResponse(BaseModel):
|
||||
service_account_revision: int
|
||||
items: list[ServiceAccountCredentialItem]
|
||||
|
||||
|
||||
class ServiceAccountCredentialCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
scopes: list[str] = Field(min_length=1, max_length=200)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ServiceAccountCredentialRotateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
scopes: list[str] | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ServiceAccountCredentialRevokeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ServiceAccountCredentialMutationResponse(BaseModel):
|
||||
service_account_revision: int
|
||||
credential: ServiceAccountCredentialItem
|
||||
|
||||
|
||||
class ServiceAccountCredentialSecretResponse(
|
||||
ServiceAccountCredentialMutationResponse
|
||||
):
|
||||
secret: str
|
||||
|
||||
|
||||
class AuditAdminItem(BaseModel):
|
||||
id: str
|
||||
scope: Literal["tenant", "system"] = "tenant"
|
||||
|
||||
@@ -3780,7 +3780,14 @@ def _api_key_items_for_response(session: Session, keys: list[ApiKey]) -> list[Ap
|
||||
|
||||
|
||||
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse:
|
||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
||||
query = (
|
||||
session.query(ApiKey)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.filter(
|
||||
ApiKey.tenant_id == tenant.id,
|
||||
User.auth_provider != "service_account",
|
||||
)
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,))
|
||||
@@ -3807,7 +3814,14 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke
|
||||
if entries is None:
|
||||
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
|
||||
changed_ids = _changed_ids(entries, "access_api_key")
|
||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
||||
query = (
|
||||
session.query(ApiKey)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.filter(
|
||||
ApiKey.tenant_id == tenant.id,
|
||||
User.auth_provider != "service_account",
|
||||
)
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||
visible = {
|
||||
@@ -3857,7 +3871,14 @@ def list_api_keys(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
||||
query = (
|
||||
session.query(ApiKey)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.filter(
|
||||
ApiKey.tenant_id == tenant.id,
|
||||
User.auth_provider != "service_account",
|
||||
)
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||
keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size)
|
||||
@@ -3880,6 +3901,14 @@ def create_tenant_api_key(
|
||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
|
||||
if user.auth_provider == "service_account":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"Use the service-account credential API so revision, scope "
|
||||
"ceiling, rotation, and audit guarantees remain enforced"
|
||||
),
|
||||
)
|
||||
user_scopes = _user_item_for_response(
|
||||
session,
|
||||
user,
|
||||
@@ -3930,7 +3959,16 @@ def revoke_api_key(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
item = session.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.tenant_id == tenant.id).one_or_none()
|
||||
item = (
|
||||
session.query(ApiKey)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.filter(
|
||||
ApiKey.id == api_key_id,
|
||||
ApiKey.tenant_id == tenant.id,
|
||||
User.auth_provider != "service_account",
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
||||
if item.revoked_at is None:
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.governance import assert_api_keys_allowed
|
||||
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
ServiceAccountCreateRequest,
|
||||
ServiceAccountCredentialCreateRequest,
|
||||
ServiceAccountCredentialItem,
|
||||
ServiceAccountCredentialListResponse,
|
||||
ServiceAccountCredentialMutationResponse,
|
||||
ServiceAccountCredentialRevokeRequest,
|
||||
ServiceAccountCredentialRotateRequest,
|
||||
ServiceAccountCredentialSecretResponse,
|
||||
ServiceAccountItem,
|
||||
ServiceAccountListResponse,
|
||||
ServiceAccountRetireRequest,
|
||||
@@ -13,14 +21,22 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
)
|
||||
from govoplan_access.backend.service_accounts import (
|
||||
ServiceAccountConflictError,
|
||||
ServiceAccountCredentialNotFoundError,
|
||||
ServiceAccountCredentialSummary,
|
||||
ServiceAccountError,
|
||||
ServiceAccountNotFoundError,
|
||||
create_service_account,
|
||||
create_service_account_credential,
|
||||
get_service_account,
|
||||
list_service_accounts,
|
||||
list_service_account_credentials,
|
||||
revoke_service_account_credential,
|
||||
retire_service_account,
|
||||
rotate_service_account_credential,
|
||||
service_account_credential_summaries,
|
||||
update_service_account,
|
||||
)
|
||||
from govoplan_core.admin.common import AdminConflictError
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
@@ -40,13 +56,18 @@ def list_managed_service_accounts(
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
items = list_service_accounts(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
summaries = service_account_credential_summaries(
|
||||
session,
|
||||
service_accounts=items,
|
||||
)
|
||||
return ServiceAccountListResponse(
|
||||
items=[
|
||||
_service_account_item(item)
|
||||
for item in list_service_accounts(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
_service_account_item(item, summaries.get(item.id))
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
|
||||
@@ -71,7 +92,11 @@ def get_managed_service_account(
|
||||
)
|
||||
except ServiceAccountError as exc:
|
||||
raise _service_account_http_error(exc) from exc
|
||||
return _service_account_item(item)
|
||||
summary = service_account_credential_summaries(
|
||||
session,
|
||||
service_accounts=(item,),
|
||||
)[item.id]
|
||||
return _service_account_item(item, summary)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -204,8 +229,204 @@ def retire_managed_service_account(
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
def _service_account_item(item: object) -> ServiceAccountItem:
|
||||
return ServiceAccountItem.model_validate(
|
||||
@router.get(
|
||||
"/{service_account_id}/credentials",
|
||||
response_model=ServiceAccountCredentialListResponse,
|
||||
)
|
||||
def list_managed_service_account_credentials(
|
||||
service_account_id: str,
|
||||
include_revoked: bool = Query(default=True),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item, credentials = list_service_account_credentials(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
include_revoked=include_revoked,
|
||||
)
|
||||
except ServiceAccountError as exc:
|
||||
raise _service_account_http_error(exc) from exc
|
||||
return ServiceAccountCredentialListResponse(
|
||||
service_account_revision=item.revision,
|
||||
items=[_credential_item(credential) for credential in credentials],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials",
|
||||
response_model=ServiceAccountCredentialSecretResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountCredentialCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
assert_api_keys_allowed(session, tenant)
|
||||
item, created = create_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
name=payload.name,
|
||||
scopes=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_created",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=created.model.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"prefix": created.model.prefix,
|
||||
"scopes": list(created.model.scopes),
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialSecretResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(created.model),
|
||||
secret=created.secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials/{credential_id}/rotate",
|
||||
response_model=ServiceAccountCredentialSecretResponse,
|
||||
)
|
||||
def rotate_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
payload: ServiceAccountCredentialRotateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
assert_api_keys_allowed(session, tenant)
|
||||
item, previous, created = rotate_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
credential_id=credential_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
name=payload.name,
|
||||
scopes=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_rotated",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=created.model.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"previous_credential_id": previous.id,
|
||||
"prefix": created.model.prefix,
|
||||
"scopes": list(created.model.scopes),
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialSecretResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(created.model),
|
||||
secret=created.secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials/{credential_id}/revoke",
|
||||
response_model=ServiceAccountCredentialMutationResponse,
|
||||
)
|
||||
def revoke_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
payload: ServiceAccountCredentialRevokeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item, credential = revoke_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
credential_id=credential_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_revoked",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=credential.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"prefix": credential.prefix,
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialMutationResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(credential),
|
||||
)
|
||||
|
||||
|
||||
def _service_account_item(
|
||||
item: object,
|
||||
summary: ServiceAccountCredentialSummary | None = None,
|
||||
) -> ServiceAccountItem:
|
||||
values = ServiceAccountItem.model_validate(
|
||||
item, from_attributes=True
|
||||
)
|
||||
if summary is None:
|
||||
return values
|
||||
return values.model_copy(
|
||||
update={
|
||||
"credential_count": summary.credential_count,
|
||||
"active_credential_count": summary.active_credential_count,
|
||||
"last_credential_used_at": summary.last_credential_used_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _credential_item(item: object) -> ServiceAccountCredentialItem:
|
||||
return ServiceAccountCredentialItem.model_validate(
|
||||
item,
|
||||
from_attributes=True,
|
||||
)
|
||||
@@ -217,6 +438,11 @@ def _service_account_http_error(exc: Exception) -> HTTPException:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, ServiceAccountCredentialNotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, ServiceAccountConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
@@ -227,6 +453,11 @@ def _service_account_http_error(exc: Exception) -> HTTPException:
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
|
||||
@@ -525,6 +525,18 @@ def _resolve_api_key_principal_context(
|
||||
or user.tenant_id != api_key.tenant_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
||||
if (
|
||||
account.auth_provider == "service_account"
|
||||
or user.auth_provider == "service_account"
|
||||
):
|
||||
return _resolve_service_account_credential_context(
|
||||
session,
|
||||
api_key=api_key,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
activity_touch_pending=activity_touch_pending,
|
||||
)
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
@@ -560,6 +572,61 @@ def _resolve_api_key_principal_context(
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
||||
|
||||
|
||||
def _resolve_service_account_credential_context(
|
||||
session: Session,
|
||||
*,
|
||||
api_key: ApiKey,
|
||||
account: Account,
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
activity_touch_pending: bool,
|
||||
) -> ResolvedPrincipalContext:
|
||||
item = (
|
||||
session.query(ServiceAccount)
|
||||
.filter(
|
||||
ServiceAccount.tenant_id == tenant.id,
|
||||
ServiceAccount.account_id == account.id,
|
||||
ServiceAccount.membership_id == user.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if (
|
||||
item is None
|
||||
or account.auth_provider != "service_account"
|
||||
or user.auth_provider != "service_account"
|
||||
or not item.is_active
|
||||
or item.retired_at is not None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Inactive or inconsistent service-account credential",
|
||||
)
|
||||
effective_scopes = intersect_api_key_scopes(
|
||||
item.scope_ceiling,
|
||||
api_key.scopes or [],
|
||||
)
|
||||
principal = PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
scopes=frozenset(effective_scopes),
|
||||
auth_method="service_account",
|
||||
api_key_id=api_key.id,
|
||||
service_account_id=item.id,
|
||||
email=None,
|
||||
display_name=item.name,
|
||||
)
|
||||
if activity_touch_pending:
|
||||
session.commit()
|
||||
return ResolvedPrincipalContext(
|
||||
principal=principal,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_session_principal_ref(
|
||||
request: Request,
|
||||
session: Session,
|
||||
|
||||
@@ -364,6 +364,8 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
|
||||
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
|
||||
DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"),
|
||||
DocumentationLink(label="Service accounts", href="/admin?section=tenant-service-accounts", kind="runtime"),
|
||||
DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"),
|
||||
),
|
||||
configuration_keys=("access_governance",),
|
||||
metadata={
|
||||
@@ -375,6 +377,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"access.admin.tenant-groups",
|
||||
"access.admin.tenant-roles",
|
||||
"access.admin.api-keys",
|
||||
"access.admin.service-accounts",
|
||||
"access.credentials",
|
||||
],
|
||||
"route": "/admin",
|
||||
@@ -443,6 +446,49 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.manage-service-account-credentials",
|
||||
title="Manage service accounts and credentials",
|
||||
summary="Create non-login automation principals, set a current scope ceiling, and rotate their one-time credentials without granting human login access.",
|
||||
body=(
|
||||
"Service accounts are tenant-owned automation principals. The account itself has no password or interactive session. Administrators first define its scope ceiling, then create one or more independently revocable credentials. "
|
||||
"A credential secret is disclosed once and only its hash and prefix remain in GovOPlaN. Runtime authorization is always the intersection of the credential scopes and the service account's current ceiling, so lowering the ceiling or deactivating the account takes effect immediately. "
|
||||
"Rotation creates the replacement and revokes the previous credential in one transaction. Retirement disables the backing principal and revokes every active credential. Every credential mutation requires the current service-account revision; a stale browser must reload instead of overwriting a concurrent change."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=32,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"access:service_account:read",
|
||||
"access:service_account:write",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Service accounts", href="/admin?section=tenant-service-accounts", kind="runtime"),
|
||||
DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"),
|
||||
DocumentationLink(label="Credential lifecycle API", href="/api/v1/admin/service-accounts/{service_account_id}/credentials", kind="api"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["access.admin.service-accounts"],
|
||||
"prerequisites": [
|
||||
"The tenant permits API credentials.",
|
||||
"You have service-account write permission and may delegate every selected scope.",
|
||||
],
|
||||
"steps": [
|
||||
"Create a service account and define the narrowest useful scope ceiling.",
|
||||
"Open the account and create a credential with an equal or narrower scope grant.",
|
||||
"Record the one-time secret in an external secret manager.",
|
||||
"Rotate credentials before expiry and revoke credentials that are no longer used.",
|
||||
],
|
||||
"verification": "The administration table shows the expected active credential count, last-use timestamp, revision, and audit events without exposing secret material.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.external-function-role-mappings",
|
||||
title="Organization function facts and access roles",
|
||||
@@ -457,7 +503,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=32,
|
||||
order=33,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access", "organizations"),
|
||||
@@ -735,6 +781,7 @@ manifest = ModuleManifest(
|
||||
ViewSurface(id="access.admin.tenant-users", module_id="access", kind="section", label="Tenant users", order=40),
|
||||
ViewSurface(id="access.admin.tenant-credentials", module_id="access", kind="section", label="Tenant credentials", order=70),
|
||||
ViewSurface(id="access.admin.tenant-api-keys", module_id="access", kind="section", label="Tenant API keys", order=80),
|
||||
ViewSurface(id="access.admin.tenant-service-accounts", module_id="access", kind="section", label="Service accounts", order=90),
|
||||
ViewSurface(id="access.admin.group-credentials", module_id="access", kind="section", label="Group credentials", order=30),
|
||||
ViewSurface(id="access.admin.user-credentials", module_id="access", kind="section", label="User credentials", order=30),
|
||||
ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -9,12 +11,19 @@ from sqlalchemy.orm import Session
|
||||
from govoplan_access.backend.db.base import utcnow
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
ServiceAccount,
|
||||
Tenant,
|
||||
User,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||
from govoplan_access.backend.security.api_keys import (
|
||||
CreatedApiKey,
|
||||
create_api_key,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
|
||||
class ServiceAccountError(ValueError):
|
||||
@@ -29,6 +38,17 @@ class ServiceAccountConflictError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountCredentialNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServiceAccountCredentialSummary:
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
|
||||
|
||||
def list_service_accounts(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -46,6 +66,195 @@ def list_service_accounts(
|
||||
)
|
||||
|
||||
|
||||
def service_account_credential_summaries(
|
||||
session: Session,
|
||||
*,
|
||||
service_accounts: Iterable[ServiceAccount],
|
||||
) -> dict[str, ServiceAccountCredentialSummary]:
|
||||
items = tuple(service_accounts)
|
||||
by_membership = {item.membership_id: item.id for item in items}
|
||||
usable_accounts = {
|
||||
item.id
|
||||
for item in items
|
||||
if item.is_active and item.retired_at is None
|
||||
}
|
||||
summaries = {
|
||||
item.id: ServiceAccountCredentialSummary()
|
||||
for item in items
|
||||
}
|
||||
if not by_membership:
|
||||
return summaries
|
||||
now = utc_now()
|
||||
totals: dict[str, int] = {}
|
||||
active: dict[str, int] = {}
|
||||
last_used: dict[str, datetime | None] = {}
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(ApiKey.user_id.in_(by_membership))
|
||||
)
|
||||
for credential in credentials:
|
||||
service_account_id = by_membership[credential.user_id]
|
||||
totals[service_account_id] = totals.get(service_account_id, 0) + 1
|
||||
expires_at = ensure_aware_utc(credential.expires_at)
|
||||
if (
|
||||
service_account_id in usable_accounts
|
||||
and
|
||||
credential.revoked_at is None
|
||||
and (expires_at is None or expires_at > now)
|
||||
):
|
||||
active[service_account_id] = (
|
||||
active.get(service_account_id, 0) + 1
|
||||
)
|
||||
used_at = ensure_aware_utc(credential.last_used_at)
|
||||
if used_at is not None and (
|
||||
last_used.get(service_account_id) is None
|
||||
or used_at > last_used[service_account_id]
|
||||
):
|
||||
last_used[service_account_id] = used_at
|
||||
return {
|
||||
item.id: ServiceAccountCredentialSummary(
|
||||
credential_count=totals.get(item.id, 0),
|
||||
active_credential_count=active.get(item.id, 0),
|
||||
last_credential_used_at=last_used.get(item.id),
|
||||
)
|
||||
for item in items
|
||||
}
|
||||
|
||||
|
||||
def list_service_account_credentials(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
include_revoked: bool = True,
|
||||
) -> tuple[ServiceAccount, list[ApiKey]]:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.where(ApiKey.revoked_at.is_(None))
|
||||
credentials = list(
|
||||
session.scalars(
|
||||
query.order_by(ApiKey.created_at.desc(), ApiKey.id)
|
||||
)
|
||||
)
|
||||
return item, credentials
|
||||
|
||||
|
||||
def create_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str,
|
||||
scopes: Iterable[str],
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, created
|
||||
|
||||
|
||||
def rotate_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str | None,
|
||||
scopes: Iterable[str] | None,
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, ApiKey, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
previous = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if previous.revoked_at is not None:
|
||||
raise ServiceAccountConflictError(
|
||||
"The credential is already revoked; reload before rotating"
|
||||
)
|
||||
requested_scopes = previous.scopes if scopes is None else scopes
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
requested_scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name or previous.name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
previous.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, previous, created
|
||||
|
||||
|
||||
def revoke_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> tuple[ServiceAccount, ApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
credential = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if credential.revoked_at is None:
|
||||
credential.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, credential
|
||||
|
||||
|
||||
def get_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -208,7 +417,7 @@ def retire_service_account(
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
return update_service_account(
|
||||
item = update_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
@@ -216,6 +425,149 @@ def retire_service_account(
|
||||
expected_revision=expected_revision,
|
||||
changes={"is_active": False},
|
||||
)
|
||||
now = utc_now()
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
for credential in credentials:
|
||||
credential.revoked_at = now
|
||||
session.flush()
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_for_credential_change(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before changing credentials"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
item: ServiceAccount,
|
||||
credential_id: str,
|
||||
) -> ApiKey:
|
||||
credential = session.scalar(
|
||||
select(ApiKey)
|
||||
.where(
|
||||
ApiKey.id == credential_id,
|
||||
ApiKey.tenant_id == item.tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if credential is None:
|
||||
raise ServiceAccountCredentialNotFoundError(
|
||||
"Service-account credential was not found"
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
def _active_service_account_membership(
|
||||
session: Session,
|
||||
item: ServiceAccount,
|
||||
) -> User:
|
||||
user = session.get(User, item.membership_id)
|
||||
account = session.get(Account, item.account_id)
|
||||
if (
|
||||
not item.is_active
|
||||
or item.retired_at is not None
|
||||
or user is None
|
||||
or account is None
|
||||
or not user.is_active
|
||||
or not account.is_active
|
||||
):
|
||||
raise ServiceAccountConflictError(
|
||||
"Activate the service account before creating or rotating credentials"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _service_account_credential_scopes(
|
||||
principal: ApiPrincipal,
|
||||
item: ServiceAccount,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if not scopes:
|
||||
raise ServiceAccountError(
|
||||
"A service-account credential requires at least one scope"
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service-account credentials support at most 200 scopes"
|
||||
)
|
||||
denied_by_ceiling = tuple(
|
||||
scope
|
||||
for scope in scopes
|
||||
if not scopes_grant(item.scope_ceiling, scope)
|
||||
)
|
||||
if denied_by_ceiling:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the service-account scope ceiling: "
|
||||
+ ", ".join(denied_by_ceiling)
|
||||
)
|
||||
denied_by_actor = tuple(
|
||||
scope for scope in scopes if not principal.has(scope)
|
||||
)
|
||||
if denied_by_actor:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the current administrator authority: "
|
||||
+ ", ".join(denied_by_actor)
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
def _credential_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Credential name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _future_expiry(value: datetime | None) -> datetime | None:
|
||||
expires_at = ensure_aware_utc(value)
|
||||
if expires_at is not None and expires_at <= utc_now():
|
||||
raise ServiceAccountError(
|
||||
"Credential expiry must be in the future"
|
||||
)
|
||||
return expires_at
|
||||
|
||||
|
||||
def _touch_service_account(
|
||||
item: ServiceAccount,
|
||||
principal: ApiPrincipal,
|
||||
) -> None:
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
|
||||
|
||||
def _service_account_name(value: str) -> str:
|
||||
@@ -273,11 +625,18 @@ def _service_account_scopes(
|
||||
|
||||
__all__ = [
|
||||
"ServiceAccountConflictError",
|
||||
"ServiceAccountCredentialNotFoundError",
|
||||
"ServiceAccountCredentialSummary",
|
||||
"ServiceAccountError",
|
||||
"ServiceAccountNotFoundError",
|
||||
"create_service_account",
|
||||
"create_service_account_credential",
|
||||
"get_service_account",
|
||||
"list_service_accounts",
|
||||
"list_service_account_credentials",
|
||||
"revoke_service_account_credential",
|
||||
"retire_service_account",
|
||||
"rotate_service_account_credential",
|
||||
"service_account_credential_summaries",
|
||||
"update_service_account",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user