feat: add canonical identity administration

This commit is contained in:
2026-08-20 12:27:36 +02:00
parent 43f0128e6a
commit 34357fe0ec
13 changed files with 1813 additions and 28 deletions
+457 -20
View File
@@ -1,36 +1,67 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from collections.abc import Sequence
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, require_any_scope
from govoplan_core.db.session import get_session
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_identity.backend.lifecycle import (
IdentityLifecycleError,
set_identity_active,
set_primary_account,
)
from .schemas import IdentityItem, IdentityListResponse
from .schemas import (
IdentityAccountLinkCreateRequest,
IdentityAccountLinkItem,
IdentityAccountLinkUpdateRequest,
IdentityCreateRequest,
IdentityItem,
IdentityLifecycleRequest,
IdentityListResponse,
IdentityUpdateRequest,
)
router = APIRouter(prefix="/identity", tags=["identity"])
IDENTITY_READ_SCOPES = (
"identity:identity:read",
"identity:identity:admin",
"identity:account_link:admin",
"identity:read",
"admin:users:read",
"system:accounts:read",
"organizations:function:assign",
)
IDENTITY_WRITE_SCOPES = (
"identity:identity:admin",
"system:accounts:update",
"access:account:update",
)
ACCOUNT_LINK_WRITE_SCOPES = (
"identity:account_link:admin",
"identity:identity:admin",
"system:accounts:update",
"access:account:update",
)
@router.get("/identities", response_model=IdentityListResponse)
def list_identities(
query: str | None = Query(default=None, min_length=1, max_length=255),
limit: int = Query(default=25, ge=1, le=100),
limit: int = Query(default=25, ge=1, le=500),
include_inactive: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_READ_SCOPES)),
) -> IdentityListResponse:
del principal
identity_query = session.query(Identity)
if not include_inactive:
identity_query = identity_query.filter(Identity.is_active.is_(True))
@@ -48,28 +79,403 @@ def list_identities(
)
)
identities = identity_query.order_by(Identity.display_name.asc(), Identity.id.asc()).limit(limit).all()
identity_ids = [identity.id for identity in identities]
links_by_identity: dict[str, list[IdentityAccountLink]] = {identity_id: [] for identity_id in identity_ids}
if identity_ids:
links = (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.account_id.asc())
.all()
)
for link in links:
links_by_identity.setdefault(link.identity_id, []).append(link)
identities = (
identity_query.order_by(Identity.display_name.asc(), Identity.id.asc())
.limit(limit)
.all()
)
links_by_identity = _links_by_identity(session, identities)
return IdentityListResponse(
identities=[
_identity_item(identity, links_by_identity.get(identity.id, []))
_identity_item(identity, links_by_identity.get(identity.id, ()))
for identity in identities
]
],
tenant_context_id=principal.tenant_id,
)
def _identity_item(identity: Identity, links: list[IdentityAccountLink]) -> IdentityItem:
@router.get("/identities/{identity_id}", response_model=IdentityItem)
def get_identity(
identity_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_READ_SCOPES)),
) -> IdentityItem:
del principal
identity = _require_identity(session, identity_id)
return _identity_item(identity, _identity_links(session, identity.id))
@router.post(
"/identities",
response_model=IdentityItem,
status_code=status.HTTP_201_CREATED,
)
def create_identity(
payload: IdentityCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
) -> IdentityItem:
identity = Identity(
display_name=_optional_text(payload.display_name),
external_subject=_optional_text(payload.external_subject),
source=payload.source.strip(),
is_active=payload.is_active,
settings=dict(payload.settings),
)
session.add(identity)
session.flush()
audit_from_principal(
session,
principal,
action="identity.created",
scope="system",
object_type="identity",
object_id=identity.id,
details={
"management_scope": "system",
"source": identity.source,
"active": identity.is_active,
},
)
session.commit()
session.refresh(identity)
return _identity_item(identity, ())
@router.patch("/identities/{identity_id}", response_model=IdentityItem)
def update_identity(
identity_id: str,
payload: IdentityUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
) -> IdentityItem:
identity = _require_identity(session, identity_id)
changed_fields: list[str] = []
supplied = payload.model_fields_set
if "display_name" in supplied:
identity.display_name = _optional_text(payload.display_name)
changed_fields.append("display_name")
if "external_subject" in supplied:
identity.external_subject = _optional_text(payload.external_subject)
changed_fields.append("external_subject")
if "source" in supplied and payload.source is not None:
identity.source = payload.source.strip()
changed_fields.append("source")
if "settings" in supplied and payload.settings is not None:
identity.settings = dict(payload.settings)
changed_fields.append("settings")
if "is_active" in supplied and payload.is_active is not None:
try:
result = set_identity_active(
session,
identity_id=identity.id,
active=payload.is_active,
actor_tenant_id=principal.tenant_id,
actor_user_id=principal.user.id,
actor_scope="system",
reason=payload.reason,
)
except IdentityLifecycleError as exc: # pragma: no cover - already loaded
raise _lifecycle_http_error(exc) from exc
if result.changed:
changed_fields.append("is_active")
if changed_fields:
audit_from_principal(
session,
principal,
action="identity.updated",
scope="system",
object_type="identity",
object_id=identity.id,
details={
"management_scope": "system",
"changed_fields": sorted(changed_fields),
"reason": _optional_text(payload.reason),
},
)
session.commit()
session.refresh(identity)
return _identity_item(identity, _identity_links(session, identity.id))
@router.post("/identities/{identity_id}/deactivate", response_model=IdentityItem)
def deactivate_identity(
identity_id: str,
payload: IdentityLifecycleRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
) -> IdentityItem:
return _set_active_response(session, principal, identity_id, False, payload.reason)
@router.post("/identities/{identity_id}/activate", response_model=IdentityItem)
def activate_identity(
identity_id: str,
payload: IdentityLifecycleRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
) -> IdentityItem:
return _set_active_response(session, principal, identity_id, True, payload.reason)
@router.post(
"/identities/{identity_id}/account-links",
response_model=IdentityItem,
status_code=status.HTTP_201_CREATED,
)
def add_account_link(
identity_id: str,
payload: IdentityAccountLinkCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
) -> IdentityItem:
identity = _require_identity(session, identity_id)
account_id = payload.account_id.strip()
existing = (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.account_id == account_id)
.first()
)
if existing is not None:
detail = (
"The account is already linked to this identity."
if existing.identity_id == identity.id
else "The account is already linked to another identity."
)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
has_links = (
session.query(IdentityAccountLink.id)
.filter(IdentityAccountLink.identity_id == identity.id)
.first()
is not None
)
link = IdentityAccountLink(
identity_id=identity.id,
account_id=account_id,
is_primary=False,
source=payload.source.strip(),
)
session.add(link)
try:
session.flush()
if payload.make_primary or not has_links:
set_primary_account(
session,
identity_id=identity.id,
account_id=account_id,
actor_tenant_id=principal.tenant_id,
actor_user_id=principal.user.id,
actor_scope="system",
reason=payload.reason,
)
audit_from_principal(
session,
principal,
action="identity.account_link_added",
scope="system",
object_type="identity_account_link",
object_id=link.id,
details={
"management_scope": "system",
"identity_id": identity.id,
"account_id": account_id,
"source": link.source,
"made_primary": payload.make_primary or not has_links,
"reason": _optional_text(payload.reason),
},
)
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The account link conflicts with an existing primary-account assignment.",
) from exc
session.refresh(identity)
return _identity_item(identity, _identity_links(session, identity.id))
@router.patch(
"/identities/{identity_id}/account-links/{link_id}",
response_model=IdentityItem,
)
def update_account_link(
identity_id: str,
link_id: str,
payload: IdentityAccountLinkUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
) -> IdentityItem:
identity = _require_identity(session, identity_id)
link = _require_link(session, identity.id, link_id)
if not payload.is_primary:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="A primary link can only be demoted by promoting a replacement.",
)
try:
set_primary_account(
session,
identity_id=identity.id,
account_id=link.account_id,
actor_tenant_id=principal.tenant_id,
actor_user_id=principal.user.id,
actor_scope="system",
reason=payload.reason,
)
except IdentityLifecycleError as exc:
raise _lifecycle_http_error(exc) from exc
session.commit()
session.refresh(identity)
return _identity_item(identity, _identity_links(session, identity.id))
@router.delete(
"/identities/{identity_id}/account-links/{link_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def remove_account_link(
identity_id: str,
link_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
) -> Response:
identity = _require_identity(session, identity_id)
link = _require_link(session, identity.id, link_id)
remaining_count = (
session.query(IdentityAccountLink)
.filter(
IdentityAccountLink.identity_id == identity.id,
IdentityAccountLink.id != link.id,
)
.count()
)
if link.is_primary and remaining_count:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Promote another account before removing the primary link.",
)
evidence = {
"management_scope": "system",
"identity_id": identity.id,
"account_id": link.account_id,
"source": link.source,
"was_primary": link.is_primary,
}
session.delete(link)
audit_from_principal(
session,
principal,
action="identity.account_link_removed",
scope="system",
object_type="identity_account_link",
object_id=link.id,
details=evidence,
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
def _set_active_response(
session: Session,
principal: ApiPrincipal,
identity_id: str,
active: bool,
reason: str | None,
) -> IdentityItem:
identity = _require_identity(session, identity_id)
try:
set_identity_active(
session,
identity_id=identity.id,
active=active,
actor_tenant_id=principal.tenant_id,
actor_user_id=principal.user.id,
actor_scope="system",
reason=reason,
)
except IdentityLifecycleError as exc:
raise _lifecycle_http_error(exc) from exc
session.commit()
session.refresh(identity)
return _identity_item(identity, _identity_links(session, identity.id))
def _require_identity(session: Session, identity_id: str) -> Identity:
identity = session.get(Identity, identity_id)
if identity is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Identity not found.",
)
return identity
def _require_link(
session: Session,
identity_id: str,
link_id: str,
) -> IdentityAccountLink:
link = (
session.query(IdentityAccountLink)
.filter(
IdentityAccountLink.id == link_id,
IdentityAccountLink.identity_id == identity_id,
)
.one_or_none()
)
if link is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Account link not found.",
)
return link
def _links_by_identity(
session: Session,
identities: Sequence[Identity],
) -> dict[str, list[IdentityAccountLink]]:
identity_ids = [identity.id for identity in identities]
result: dict[str, list[IdentityAccountLink]] = {
identity_id: [] for identity_id in identity_ids
}
if not identity_ids:
return result
links = (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
.order_by(
IdentityAccountLink.identity_id.asc(),
IdentityAccountLink.is_primary.desc(),
IdentityAccountLink.account_id.asc(),
)
.all()
)
for link in links:
result.setdefault(link.identity_id, []).append(link)
return result
def _identity_links(
session: Session,
identity_id: str,
) -> list[IdentityAccountLink]:
return (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id == identity_id)
.order_by(
IdentityAccountLink.is_primary.desc(),
IdentityAccountLink.account_id.asc(),
)
.all()
)
def _identity_item(
identity: Identity,
links: Sequence[IdentityAccountLink],
) -> IdentityItem:
primary_link = next((link for link in links if link.is_primary), None)
return IdentityItem(
id=identity.id,
@@ -78,5 +484,36 @@ def _identity_item(identity: Identity, links: list[IdentityAccountLink]) -> Iden
source=identity.source,
primary_account_id=primary_link.account_id if primary_link is not None else None,
account_ids=[link.account_id for link in links],
account_links=[_account_link_item(link) for link in links],
status="active" if identity.is_active else "inactive",
is_active=identity.is_active,
settings=dict(identity.settings or {}),
created_at=identity.created_at,
updated_at=identity.updated_at,
)
def _account_link_item(link: IdentityAccountLink) -> IdentityAccountLinkItem:
return IdentityAccountLinkItem(
id=link.id,
identity_id=link.identity_id,
account_id=link.account_id,
is_primary=link.is_primary,
source=link.source,
created_at=link.created_at,
updated_at=link.updated_at,
)
def _optional_text(value: Any) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _lifecycle_http_error(exc: IdentityLifecycleError) -> HTTPException:
code = (
status.HTTP_404_NOT_FOUND
if exc.code == "identity_not_found"
else status.HTTP_409_CONFLICT
)
return HTTPException(status_code=code, detail=str(exc))
@@ -1,6 +1,19 @@
from __future__ import annotations
from pydantic import BaseModel
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class IdentityAccountLinkItem(BaseModel):
id: str
identity_id: str
account_id: str
is_primary: bool
source: str
created_at: datetime
updated_at: datetime
class IdentityItem(BaseModel):
@@ -10,8 +23,59 @@ class IdentityItem(BaseModel):
source: str
primary_account_id: str | None = None
account_ids: list[str]
status: str
account_links: list[IdentityAccountLinkItem] = Field(default_factory=list)
status: Literal["active", "inactive"]
is_active: bool
settings: dict[str, Any] = Field(default_factory=dict)
management_scope: Literal["system"] = "system"
created_at: datetime
updated_at: datetime
class IdentityListResponse(BaseModel):
identities: list[IdentityItem]
management_scope: Literal["system"] = "system"
tenant_context_id: str | None = None
class IdentityCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
external_subject: str | None = Field(default=None, max_length=255)
source: str = Field(default="local", min_length=1, max_length=50)
is_active: bool = True
settings: dict[str, Any] = Field(default_factory=dict)
class IdentityUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
external_subject: str | None = Field(default=None, max_length=255)
source: str | None = Field(default=None, min_length=1, max_length=50)
is_active: bool | None = None
settings: dict[str, Any] | None = None
reason: str | None = Field(default=None, max_length=500)
class IdentityLifecycleRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str | None = Field(default=None, max_length=500)
class IdentityAccountLinkCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
account_id: str = Field(min_length=1, max_length=36)
source: str = Field(default="local", min_length=1, max_length=50)
make_primary: bool = False
reason: str | None = Field(default=None, max_length=500)
class IdentityAccountLinkUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
is_primary: bool
reason: str | None = Field(default=None, max_length=500)
@@ -30,6 +30,7 @@ def set_identity_active(
active: bool,
actor_tenant_id: str | None,
actor_user_id: str | None,
actor_scope: str = "tenant",
reason: str | None = None,
) -> IdentityLifecycleResult:
"""Change directory visibility without deleting identity or link evidence.
@@ -55,6 +56,7 @@ def set_identity_active(
session,
tenant_id=actor_tenant_id,
user_id=actor_user_id,
scope=actor_scope,
action="identity.activated" if desired else "identity.deactivated",
object_type="identity",
object_id=identity.id,
@@ -75,6 +77,7 @@ def set_primary_account(
account_id: str,
actor_tenant_id: str | None,
actor_user_id: str | None,
actor_scope: str = "tenant",
reason: str | None = None,
) -> IdentityLifecycleResult:
"""Atomically promote an existing link and retain every other account link."""
@@ -129,11 +132,16 @@ def set_primary_account(
with session.begin_nested():
if previous is not None:
previous.is_primary = False
# Partial unique indexes are evaluated per statement. Persist the
# demotion before the promotion so SQLite and PostgreSQL never see
# two primary links during the transition.
session.flush()
target.is_primary = True
audit_event(
session,
tenant_id=actor_tenant_id,
user_id=actor_user_id,
scope=actor_scope,
action="identity.primary_account_changed",
object_type="identity",
object_id=identity.id,
+83 -3
View File
@@ -5,20 +5,35 @@ from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import DocumentationTopic, MigrationSpec, ModuleContext, ModuleManifest, PermissionDefinition, RoleTemplate
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
ViewSurface,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.db.base import Base
from govoplan_identity.backend.db import models as identity_models # noqa: F401 - populate metadata
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
def _permission(
scope: str,
label: str,
description: str,
*,
level: str = "tenant",
) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Identity",
level="tenant",
level=level,
module_id=module_id,
resource=resource,
action=action,
@@ -27,6 +42,18 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = (
_permission("identity:identity:read", "View identities", "Search and read normalized identities and their account links."),
_permission(
"identity:identity:admin",
"Administer identities",
"Create, update, activate, and deactivate canonical system identities.",
level="system",
),
_permission(
"identity:account_link:admin",
"Administer identity account links",
"Add, remove, and select the primary platform account for a canonical identity.",
level="system",
),
)
ROLE_TEMPLATES = (
@@ -36,6 +63,16 @@ ROLE_TEMPLATES = (
description="Read normalized identities and account links.",
permissions=("identity:identity:read",),
),
RoleTemplate(
slug="identity_administrator",
name="Identity administrator",
description="Administer the canonical system identity directory and account links.",
permissions=(
"identity:identity:read",
"identity:identity:admin",
"identity:account_link:admin",
),
),
)
@@ -61,6 +98,27 @@ manifest = ModuleManifest(
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_route_factory,
frontend=FrontendModule(
module_id="identity",
package_name="@govoplan/identity-webui",
view_surfaces=(
ViewSurface(
id="identity.admin.directory",
module_id="identity",
kind="section",
label="Identity directory",
order=30,
),
ViewSurface(
id="identity.admin.account-links",
module_id="identity",
kind="section",
label="Identity account links",
parent_id="identity.admin.directory",
order=20,
),
),
),
migration_spec=MigrationSpec(
module_id="identity",
metadata=Base.metadata,
@@ -92,6 +150,28 @@ manifest = ModuleManifest(
audience=("tenant_admin", "access_admin", "operator"),
order=24,
),
DocumentationTopic(
id="identity.administration",
title="Administer the canonical identity directory",
summary="Manage system-scoped identities and their account links without taking over authentication or access control.",
body=(
"The Identity administration surface lists, creates, inspects, updates, deactivates, and reactivates canonical identities. These records are system-scoped; the current tenant is shown only as the acting administrative context. Account references remain opaque to Identity and one account can be linked to only one identity through this administration API. The first link becomes primary automatically. A primary link cannot be removed while another link remains: promote the replacement first. Every write and primary-account transition is recorded as a system audit event. Deactivation is reversible and does not suspend authentication, erase links, or change permissions."
),
layer="configured",
documentation_types=("admin",),
audience=("system_admin", "identity_admin", "access_admin"),
order=26,
metadata={
"kind": "guide",
"help_contexts": ["identity.admin.directory"],
"prerequisites": [
"The administrator has system identity administration permission.",
"Account IDs are obtained from an authorized Access administration workflow.",
],
"outcome": "Canonical identity and link state changes atomically with system-scoped audit evidence.",
"verification": "Reload the identity, verify its primary marker and lifecycle state, then inspect the corresponding system audit records.",
},
),
DocumentationTopic(
id="identity.lifecycle",
title="Administer identity and account-link lifecycle",