feat(identity): enforce lifecycle audit semantics

This commit is contained in:
2026-08-19 23:19:10 +02:00
parent f4938c9666
commit 43f0128e6a
4 changed files with 345 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
class IdentityLifecycleError(ValueError):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@dataclass(frozen=True, slots=True)
class IdentityLifecycleResult:
identity_id: str
changed: bool
previous_primary_account_id: str | None = None
primary_account_id: str | None = None
active: bool | None = None
def set_identity_active(
session: Session,
*,
identity_id: str,
active: bool,
actor_tenant_id: str | None,
actor_user_id: str | None,
reason: str | None = None,
) -> IdentityLifecycleResult:
"""Change directory visibility without deleting identity or link evidence.
The caller owns authorization and the outer transaction. No commit occurs
here, so an API, import, or reconciliation owner can roll the state and its
audit record back together.
"""
identity = session.get(Identity, identity_id)
if identity is None:
raise IdentityLifecycleError("identity_not_found", "Identity not found.")
desired = bool(active)
if identity.is_active == desired:
return IdentityLifecycleResult(
identity_id=identity.id,
changed=False,
active=identity.is_active,
)
with session.begin_nested():
identity.is_active = desired
audit_event(
session,
tenant_id=actor_tenant_id,
user_id=actor_user_id,
action="identity.activated" if desired else "identity.deactivated",
object_type="identity",
object_id=identity.id,
details={"active": desired, "reason": _bounded_reason(reason)},
)
session.flush()
return IdentityLifecycleResult(
identity_id=identity.id,
changed=True,
active=identity.is_active,
)
def set_primary_account(
session: Session,
*,
identity_id: str,
account_id: str,
actor_tenant_id: str | None,
actor_user_id: str | None,
reason: str | None = None,
) -> IdentityLifecycleResult:
"""Atomically promote an existing link and retain every other account link."""
identity = session.get(Identity, identity_id)
if identity is None:
raise IdentityLifecycleError("identity_not_found", "Identity not found.")
target = (
session.query(IdentityAccountLink)
.filter(
IdentityAccountLink.identity_id == identity.id,
IdentityAccountLink.account_id == account_id,
)
.one_or_none()
)
if target is None:
raise IdentityLifecycleError(
"account_not_linked",
"The requested account is not linked to this identity.",
)
conflicting_primary = (
session.query(IdentityAccountLink)
.filter(
IdentityAccountLink.account_id == account_id,
IdentityAccountLink.identity_id != identity.id,
IdentityAccountLink.is_primary.is_(True),
)
.first()
)
if conflicting_primary is not None:
raise IdentityLifecycleError(
"account_primary_elsewhere",
"The requested account is already primary for another identity.",
)
previous = (
session.query(IdentityAccountLink)
.filter(
IdentityAccountLink.identity_id == identity.id,
IdentityAccountLink.is_primary.is_(True),
)
.one_or_none()
)
previous_account_id = previous.account_id if previous is not None else None
if previous is not None and previous.id == target.id:
return IdentityLifecycleResult(
identity_id=identity.id,
changed=False,
previous_primary_account_id=previous_account_id,
primary_account_id=target.account_id,
active=identity.is_active,
)
with session.begin_nested():
if previous is not None:
previous.is_primary = False
target.is_primary = True
audit_event(
session,
tenant_id=actor_tenant_id,
user_id=actor_user_id,
action="identity.primary_account_changed",
object_type="identity",
object_id=identity.id,
details={
"previous_primary_account_id": previous_account_id,
"primary_account_id": target.account_id,
"link_id": target.id,
"link_source": target.source,
"reason": _bounded_reason(reason),
},
)
session.flush()
return IdentityLifecycleResult(
identity_id=identity.id,
changed=True,
previous_primary_account_id=previous_account_id,
primary_account_id=target.account_id,
active=identity.is_active,
)
def _bounded_reason(reason: str | None) -> str | None:
normalized = str(reason or "").strip()
return normalized[:500] or None
__all__ = [
"IdentityLifecycleError",
"IdentityLifecycleResult",
"set_identity_active",
"set_primary_account",
]
+22
View File
@@ -92,6 +92,28 @@ manifest = ModuleManifest(
audience=("tenant_admin", "access_admin", "operator"),
order=24,
),
DocumentationTopic(
id="identity.lifecycle",
title="Administer identity and account-link lifecycle",
summary="Deactivate identities reversibly and change the primary account without discarding link provenance.",
body=(
"Deactivation removes an identity from ordinary search while direct resolution retains an explicit inactive record; it preserves all account links and is not account suspension or erasure. "
"A primary-account change selects an existing link, atomically demotes the previous primary, preserves multiple-account compatibility, and records actor, old/new account, and link-source evidence. The authorized caller commits or rolls back state and audit evidence together."
),
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "access_admin", "operator"),
order=25,
metadata={
"kind": "reference",
"prerequisites": [
"The caller has separately established lifecycle authority.",
"The replacement primary account is already linked to the identity.",
],
"outcome": "Identity visibility or primary-account state changes without deleting account-link provenance.",
"verification": "Confirm ordinary versus include-inactive directory results, inspect every retained account link, and review the matching identity lifecycle audit record.",
},
),
),
architecture=declared_module_architecture(
layer="institutional_foundation",