feat(identity): enforce lifecycle audit semantics
This commit is contained in:
@@ -10,6 +10,32 @@ authenticate and independent of what they may do.
|
|||||||
- Primary account: the account used as the default display/explainability
|
- Primary account: the account used as the default display/explainability
|
||||||
anchor when multiple accounts exist.
|
anchor when multiple accounts exist.
|
||||||
|
|
||||||
|
## Lifecycle semantics
|
||||||
|
|
||||||
|
An active identity is eligible for ordinary directory search. Deactivation
|
||||||
|
removes it from default search results but does not delete the identity, its
|
||||||
|
account links, their source provenance, or the primary-account marker. Direct
|
||||||
|
identifier/account resolution retains the record with an explicit `inactive`
|
||||||
|
status so Access and reconcilers do not mistake deactivation for absence.
|
||||||
|
Authorized lifecycle owners may also include inactive identities in search and
|
||||||
|
may reactivate them. Deactivation is therefore a reversible directory-state
|
||||||
|
change, not account suspension or erasure; Access owns those separate
|
||||||
|
consequences.
|
||||||
|
|
||||||
|
Each account link records the origin of the accepted association in `source`
|
||||||
|
(for example `local` or an IDM reconciliation source). The source is provenance,
|
||||||
|
not authorization and not proof that the external source remains reachable.
|
||||||
|
Changing the primary account never rewrites this origin.
|
||||||
|
|
||||||
|
An identity may retain multiple account links but has at most one primary
|
||||||
|
account. A primary-account change may select only an existing link belonging to
|
||||||
|
that identity, atomically demotes the previous primary, preserves every link,
|
||||||
|
and records old/new account ids plus link-source provenance in the audit log.
|
||||||
|
The lifecycle service does not commit: its caller authorizes the operation and
|
||||||
|
commits the state and audit record together, or rolls both back on validation,
|
||||||
|
audit, or persistence failure. Repeating the already-effective selection is a
|
||||||
|
no-op and does not create misleading audit activity.
|
||||||
|
|
||||||
## Boundary With Access
|
## Boundary With Access
|
||||||
|
|
||||||
Access owns authorization. Identity only tells access which identity is behind
|
Access owns authorization. Identity only tells access which identity is behind
|
||||||
@@ -48,3 +74,7 @@ Rollout plan:
|
|||||||
|
|
||||||
The close-out condition is that Access works with canonical Identity installed
|
The close-out condition is that Access works with canonical Identity installed
|
||||||
and still works without it through the projection fallback.
|
and still works without it through the projection fallback.
|
||||||
|
|
||||||
|
The current lifecycle service is intentionally not an administration API.
|
||||||
|
Identity administration screens and endpoint permissions remain tracked
|
||||||
|
separately; IDM continues to own external import/reconciliation decisions.
|
||||||
|
|||||||
@@ -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"),
|
audience=("tenant_admin", "access_admin", "operator"),
|
||||||
order=24,
|
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(
|
architecture=declared_module_architecture(
|
||||||
layer="institutional_foundation",
|
layer="institutional_foundation",
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||||
|
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||||
|
from govoplan_identity.backend.lifecycle import (
|
||||||
|
IdentityLifecycleError,
|
||||||
|
set_identity_active,
|
||||||
|
set_primary_account,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityLifecycleTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.database = configure_database("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.database.engine,
|
||||||
|
tables=[Identity.__table__, IdentityAccountLink.__table__],
|
||||||
|
)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add(Identity(id="identity-1", display_name="Ada", source="local", is_active=True, settings={}))
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
IdentityAccountLink(id="link-1", identity_id="identity-1", account_id="account-1", is_primary=True, source="local"),
|
||||||
|
IdentityAccountLink(id="link-2", identity_id="identity-1", account_id="account-2", is_primary=False, source="idm:accepted"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
|
@patch("govoplan_identity.backend.lifecycle.audit_event")
|
||||||
|
def test_primary_account_change_preserves_all_links_and_audits_source(self, audit) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
result = set_primary_account(
|
||||||
|
session,
|
||||||
|
identity_id="identity-1",
|
||||||
|
account_id="account-2",
|
||||||
|
actor_tenant_id="tenant-1",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
reason="Preferred institutional account",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertTrue(result.changed)
|
||||||
|
self.assertEqual("account-1", result.previous_primary_account_id)
|
||||||
|
self.assertEqual("account-2", result.primary_account_id)
|
||||||
|
with self.database.session() as session:
|
||||||
|
links = session.query(IdentityAccountLink).filter_by(identity_id="identity-1").order_by(IdentityAccountLink.account_id).all()
|
||||||
|
self.assertEqual([False, True], [item.is_primary for item in links])
|
||||||
|
resolved = SqlIdentityDirectory().get_identity("identity-1")
|
||||||
|
self.assertIsNotNone(resolved)
|
||||||
|
self.assertEqual(("account-1", "account-2"), tuple(sorted(resolved.account_ids)))
|
||||||
|
self.assertEqual("account-2", resolved.primary_account_id)
|
||||||
|
self.assertEqual("identity.primary_account_changed", audit.call_args.kwargs["action"])
|
||||||
|
self.assertEqual("idm:accepted", audit.call_args.kwargs["details"]["link_source"])
|
||||||
|
|
||||||
|
@patch("govoplan_identity.backend.lifecycle.audit_event")
|
||||||
|
def test_deactivation_is_reversible_and_preserves_account_links(self, audit) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
changed = set_identity_active(
|
||||||
|
session,
|
||||||
|
identity_id="identity-1",
|
||||||
|
active=False,
|
||||||
|
actor_tenant_id="tenant-1",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.assertTrue(changed.changed)
|
||||||
|
inactive = SqlIdentityDirectory().get_identity("identity-1")
|
||||||
|
self.assertIsNotNone(inactive)
|
||||||
|
self.assertEqual("inactive", inactive.status)
|
||||||
|
self.assertEqual((), SqlIdentityDirectory().search_identities())
|
||||||
|
with self.database.session() as session:
|
||||||
|
self.assertEqual(2, session.query(IdentityAccountLink).filter_by(identity_id="identity-1").count())
|
||||||
|
set_identity_active(
|
||||||
|
session,
|
||||||
|
identity_id="identity-1",
|
||||||
|
active=True,
|
||||||
|
actor_tenant_id="tenant-1",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.assertIsNotNone(SqlIdentityDirectory().get_identity("identity-1"))
|
||||||
|
self.assertEqual(["identity.deactivated", "identity.activated"], [call.kwargs["action"] for call in audit.call_args_list])
|
||||||
|
|
||||||
|
@patch("govoplan_identity.backend.lifecycle.audit_event")
|
||||||
|
def test_invalid_primary_change_does_not_mutate_or_audit(self, audit) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
with self.assertRaises(IdentityLifecycleError) as raised:
|
||||||
|
set_primary_account(
|
||||||
|
session,
|
||||||
|
identity_id="identity-1",
|
||||||
|
account_id="not-linked",
|
||||||
|
actor_tenant_id="tenant-1",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
self.assertEqual("account_not_linked", raised.exception.code)
|
||||||
|
links = session.query(IdentityAccountLink).filter_by(identity_id="identity-1").order_by(IdentityAccountLink.account_id).all()
|
||||||
|
self.assertEqual([True, False], [item.is_primary for item in links])
|
||||||
|
audit.assert_not_called()
|
||||||
|
|
||||||
|
@patch("govoplan_identity.backend.lifecycle.audit_event", side_effect=RuntimeError("audit unavailable"))
|
||||||
|
def test_primary_change_rolls_back_when_audit_cannot_be_recorded(self, _audit) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
|
||||||
|
set_primary_account(
|
||||||
|
session,
|
||||||
|
identity_id="identity-1",
|
||||||
|
account_id="account-2",
|
||||||
|
actor_tenant_id="tenant-1",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
session.expire_all()
|
||||||
|
links = session.query(IdentityAccountLink).filter_by(identity_id="identity-1").order_by(IdentityAccountLink.account_id).all()
|
||||||
|
self.assertEqual([True, False], [item.is_primary for item in links])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user