feat(identity): enforce lifecycle audit semantics
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user