Complete effective assignment expiry lifecycle
This commit is contained in:
@@ -133,6 +133,12 @@ change record before the effective assignment is created. The target journeys,
|
|||||||
grant profiles, state model, and module boundaries are documented in
|
grant profiles, state model, and module boundaries are documented in
|
||||||
[Function assignment request and grant workflows](docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md).
|
[Function assignment request and grant workflows](docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md).
|
||||||
|
|
||||||
|
Assignments with a future `valid_until` are resolved as ineffective immediately
|
||||||
|
after that boundary. When Celery beat and an IDM-capable worker are running, the
|
||||||
|
shared `govoplan.idm.expire_assignments` task also emits the corresponding
|
||||||
|
`idm.function_assignment.expired.v1` event. IDM records the event marker in the
|
||||||
|
same database transaction, making repeated sweeps idempotent.
|
||||||
|
|
||||||
## First Milestone
|
## First Milestone
|
||||||
|
|
||||||
The first useful milestone is a read-only synchronization preview:
|
The first useful milestone is a read-only synchronization preview:
|
||||||
|
|||||||
@@ -16,13 +16,6 @@ from govoplan_core.core.configuration_control import (
|
|||||||
ensure_configuration_change_allowed,
|
ensure_configuration_change_allowed,
|
||||||
record_configuration_change_applied,
|
record_configuration_change_applied,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.events import (
|
|
||||||
EventActorRef,
|
|
||||||
EventObjectRef,
|
|
||||||
EventTenantRef,
|
|
||||||
PlatformEvent,
|
|
||||||
emit_platform_event,
|
|
||||||
)
|
|
||||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
from govoplan_core.core.identity import (
|
from govoplan_core.core.identity import (
|
||||||
CAPABILITY_IDENTITY_DIRECTORY,
|
CAPABILITY_IDENTITY_DIRECTORY,
|
||||||
@@ -42,11 +35,13 @@ from govoplan_core.security.time import utc_now
|
|||||||
from govoplan_idm.backend.assignment_transitions import (
|
from govoplan_idm.backend.assignment_transitions import (
|
||||||
AssignmentMutationPlan,
|
AssignmentMutationPlan,
|
||||||
AssignmentTransitionError,
|
AssignmentTransitionError,
|
||||||
|
assignment_is_expired,
|
||||||
lifecycle_event_types,
|
lifecycle_event_types,
|
||||||
plan_assignment_update,
|
plan_assignment_update,
|
||||||
validate_assignment_shape,
|
validate_assignment_shape,
|
||||||
validate_assignment_source_rules,
|
validate_assignment_source_rules,
|
||||||
)
|
)
|
||||||
|
from govoplan_idm.backend.assignment_events import emit_assignment_event
|
||||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
||||||
|
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
@@ -436,45 +431,12 @@ def _publish_assignment_event(
|
|||||||
*,
|
*,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
emit_platform_event(
|
emit_assignment_event(
|
||||||
session,
|
session,
|
||||||
PlatformEvent(
|
item,
|
||||||
type=event_type,
|
event_type=event_type,
|
||||||
module_id="idm",
|
actor_type="account",
|
||||||
payload={
|
actor_id=principal.account_id,
|
||||||
"identity_id": item.identity_id,
|
|
||||||
"account_id": item.account_id,
|
|
||||||
"function_id": item.function_id,
|
|
||||||
"organization_unit_id": item.organization_unit_id,
|
|
||||||
"source": item.source,
|
|
||||||
"delegated_from_assignment_id": (
|
|
||||||
item.delegated_from_assignment_id
|
|
||||||
),
|
|
||||||
"acting_for_account_id": item.acting_for_account_id,
|
|
||||||
"valid_from": (
|
|
||||||
item.valid_from.isoformat()
|
|
||||||
if item.valid_from is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"valid_until": (
|
|
||||||
item.valid_until.isoformat()
|
|
||||||
if item.valid_until is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"is_active": item.is_active,
|
|
||||||
},
|
|
||||||
actor=EventActorRef(type="account", id=principal.account_id),
|
|
||||||
tenant=EventTenantRef(id=principal.tenant_id),
|
|
||||||
subject=EventObjectRef(
|
|
||||||
type="organization_function",
|
|
||||||
id=item.function_id,
|
|
||||||
),
|
|
||||||
resource=EventObjectRef(
|
|
||||||
type="organization_function_assignment",
|
|
||||||
id=item.id,
|
|
||||||
),
|
|
||||||
classification="internal",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -621,6 +583,15 @@ def create_organization_function_assignment(
|
|||||||
result,
|
result,
|
||||||
event_type="idm.function_assignment.created.v1",
|
event_type="idm.function_assignment.created.v1",
|
||||||
)
|
)
|
||||||
|
now = utc_now()
|
||||||
|
if assignment_is_expired(item, now=now):
|
||||||
|
item.expired_event_at = now
|
||||||
|
_publish_assignment_event(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
_assignment_item(item),
|
||||||
|
event_type="idm.function_assignment.expired.v1",
|
||||||
|
)
|
||||||
_commit_assignment_transaction(session, item)
|
_commit_assignment_transaction(session, item)
|
||||||
return _assignment_item(item)
|
return _assignment_item(item)
|
||||||
|
|
||||||
@@ -643,6 +614,16 @@ def update_organization_function_assignment(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
)
|
)
|
||||||
plan.apply(item)
|
plan.apply(item)
|
||||||
|
now = utc_now()
|
||||||
|
event_types = lifecycle_event_types(
|
||||||
|
plan.before,
|
||||||
|
plan.after,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
if "idm.function_assignment.expired.v1" in event_types:
|
||||||
|
item.expired_event_at = now
|
||||||
|
elif not assignment_is_expired(item, now=now):
|
||||||
|
item.expired_event_at = None
|
||||||
_flush_assignment(session, item)
|
_flush_assignment(session, item)
|
||||||
result = _assignment_item(item)
|
result = _assignment_item(item)
|
||||||
after = result.model_dump(mode="json")
|
after = result.model_dump(mode="json")
|
||||||
@@ -656,11 +637,7 @@ def update_organization_function_assignment(
|
|||||||
before=before,
|
before=before,
|
||||||
after=after,
|
after=after,
|
||||||
)
|
)
|
||||||
for event_type in lifecycle_event_types(
|
for event_type in event_types:
|
||||||
plan.before,
|
|
||||||
plan.after,
|
|
||||||
now=utc_now(),
|
|
||||||
):
|
|
||||||
_publish_assignment_event(
|
_publish_assignment_event(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class OrganizationFunctionAssignmentItem(BaseModel):
|
|||||||
acting_for_account_id: str | None = None
|
acting_for_account_id: str | None = None
|
||||||
valid_from: datetime | None = None
|
valid_from: datetime | None = None
|
||||||
valid_until: datetime | None = None
|
valid_until: datetime | None = None
|
||||||
|
expired_event_at: datetime | None = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
settings: dict[str, Any]
|
settings: dict[str, Any]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentEventSource(Protocol):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
identity_id: str
|
||||||
|
account_id: str | None
|
||||||
|
function_id: str
|
||||||
|
organization_unit_id: str
|
||||||
|
applies_to_subunits: bool
|
||||||
|
source: str
|
||||||
|
delegated_from_assignment_id: str | None
|
||||||
|
acting_for_account_id: str | None
|
||||||
|
valid_from: datetime | None
|
||||||
|
valid_until: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
def emit_assignment_event(
|
||||||
|
session: Session,
|
||||||
|
item: AssignmentEventSource,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
actor_type: str,
|
||||||
|
actor_id: str | None = None,
|
||||||
|
occurred_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
event_options = {"occurred_at": occurred_at} if occurred_at else {}
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=event_type,
|
||||||
|
module_id="idm",
|
||||||
|
payload={
|
||||||
|
"identity_id": item.identity_id,
|
||||||
|
"account_id": item.account_id,
|
||||||
|
"function_id": item.function_id,
|
||||||
|
"organization_unit_id": item.organization_unit_id,
|
||||||
|
"applies_to_subunits": item.applies_to_subunits,
|
||||||
|
"source": item.source,
|
||||||
|
"delegated_from_assignment_id": (
|
||||||
|
item.delegated_from_assignment_id
|
||||||
|
),
|
||||||
|
"acting_for_account_id": item.acting_for_account_id,
|
||||||
|
"valid_from": (
|
||||||
|
item.valid_from.isoformat()
|
||||||
|
if item.valid_from is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"valid_until": (
|
||||||
|
item.valid_until.isoformat()
|
||||||
|
if item.valid_until is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"is_active": item.is_active,
|
||||||
|
},
|
||||||
|
actor=EventActorRef(type=actor_type, id=actor_id),
|
||||||
|
tenant=EventTenantRef(id=item.tenant_id),
|
||||||
|
subject=EventObjectRef(
|
||||||
|
type="organization_function",
|
||||||
|
id=item.function_id,
|
||||||
|
),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="organization_function_assignment",
|
||||||
|
id=item.id,
|
||||||
|
),
|
||||||
|
classification="internal",
|
||||||
|
**event_options,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["emit_assignment_event"]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
from govoplan_idm.backend.assignment_events import emit_assignment_event
|
||||||
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||||
|
|
||||||
|
|
||||||
|
class SqlIdmAssignmentLifecycle:
|
||||||
|
"""Claim and publish elapsed assignments exactly once per validity window."""
|
||||||
|
|
||||||
|
def process_expired(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("IDM assignment lifecycle requires a SQLAlchemy session")
|
||||||
|
if limit < 1 or limit > 1000:
|
||||||
|
raise ValueError("IDM assignment expiry limit must be between 1 and 1000")
|
||||||
|
now = ensure_aware_utc(effective_at) or utc_now()
|
||||||
|
query = session.query(IdmOrganizationFunctionAssignment).filter(
|
||||||
|
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until.is_not(None),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until <= now,
|
||||||
|
IdmOrganizationFunctionAssignment.expired_event_at.is_(None),
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
candidates = (
|
||||||
|
query.order_by(
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until.asc(),
|
||||||
|
IdmOrganizationFunctionAssignment.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
expired_ids: list[str] = []
|
||||||
|
touched_tenants: set[str] = set()
|
||||||
|
for item in candidates:
|
||||||
|
claimed = (
|
||||||
|
session.query(IdmOrganizationFunctionAssignment)
|
||||||
|
.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.id == item.id,
|
||||||
|
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until.is_not(None),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until <= now,
|
||||||
|
IdmOrganizationFunctionAssignment.expired_event_at.is_(None),
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{IdmOrganizationFunctionAssignment.expired_event_at: now},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if claimed != 1:
|
||||||
|
continue
|
||||||
|
session.refresh(item)
|
||||||
|
emit_assignment_event(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
event_type="idm.function_assignment.expired.v1",
|
||||||
|
actor_type="system",
|
||||||
|
occurred_at=now,
|
||||||
|
)
|
||||||
|
expired_ids.append(item.id)
|
||||||
|
touched_tenants.add(item.tenant_id)
|
||||||
|
|
||||||
|
for touched_tenant_id in sorted(touched_tenants):
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=touched_tenant_id,
|
||||||
|
source_module="idm",
|
||||||
|
resource_type="organization_function_assignment_expiry",
|
||||||
|
resource_id=touched_tenant_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"selected": len(candidates),
|
||||||
|
"expired": len(expired_ids),
|
||||||
|
"assignment_ids": expired_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SqlIdmAssignmentLifecycle"]
|
||||||
@@ -200,15 +200,35 @@ def lifecycle_event_types(
|
|||||||
events = ["idm.function_assignment.changed.v1"]
|
events = ["idm.function_assignment.changed.v1"]
|
||||||
if before.is_active and not after.is_active:
|
if before.is_active and not after.is_active:
|
||||||
events.append("idm.function_assignment.revoked.v1")
|
events.append("idm.function_assignment.revoked.v1")
|
||||||
if (
|
before_expired = _is_expired(before, now=now)
|
||||||
after.valid_until is not None
|
after_expired = _is_expired(after, now=now)
|
||||||
and _comparable_datetime(after.valid_until) <= _comparable_datetime(now)
|
if after_expired and (
|
||||||
and before.valid_until != after.valid_until
|
not before_expired or before.valid_until != after.valid_until
|
||||||
):
|
):
|
||||||
events.append("idm.function_assignment.expired.v1")
|
events.append("idm.function_assignment.expired.v1")
|
||||||
return tuple(events)
|
return tuple(events)
|
||||||
|
|
||||||
|
|
||||||
|
def assignment_is_expired(
|
||||||
|
item: AssignmentLike | AssignmentSnapshot,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
return _is_expired(item, now=now)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_expired(
|
||||||
|
item: AssignmentLike | AssignmentSnapshot,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
item.is_active
|
||||||
|
and item.valid_until is not None
|
||||||
|
and _comparable_datetime(item.valid_until) <= _comparable_datetime(now)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_delegated_assignment(
|
def _validate_delegated_assignment(
|
||||||
item: AssignmentLike,
|
item: AssignmentLike,
|
||||||
*,
|
*,
|
||||||
@@ -288,6 +308,7 @@ __all__ = [
|
|||||||
"AssignmentMutationPlan",
|
"AssignmentMutationPlan",
|
||||||
"AssignmentSnapshot",
|
"AssignmentSnapshot",
|
||||||
"AssignmentTransitionError",
|
"AssignmentTransitionError",
|
||||||
|
"assignment_is_expired",
|
||||||
"lifecycle_event_types",
|
"lifecycle_event_types",
|
||||||
"plan_assignment_update",
|
"plan_assignment_update",
|
||||||
"validate_assignment_shape",
|
"validate_assignment_shape",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from govoplan_core.db.base import Base, TimestampMixin
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
@@ -24,6 +24,12 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
|
|||||||
"organization_unit_id",
|
"organization_unit_id",
|
||||||
name="uq_idm_org_function_assignments_identity_scope",
|
name="uq_idm_org_function_assignments_identity_scope",
|
||||||
),
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
"is_active",
|
||||||
|
"expired_event_at",
|
||||||
|
"valid_until",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
@@ -38,6 +44,7 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
|
|||||||
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
expired_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
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, IdentityDirectory
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH, IdentityDirectory
|
||||||
from govoplan_core.core.idm import (
|
from govoplan_core.core.idm import (
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||||
CAPABILITY_IDM_DIRECTORY,
|
CAPABILITY_IDM_DIRECTORY,
|
||||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
)
|
)
|
||||||
@@ -111,6 +112,15 @@ def _idm_directory(context: ModuleContext) -> object:
|
|||||||
return SqlIdmDirectory(identities=identities, organizations=organizations)
|
return SqlIdmDirectory(identities=identities, organizations=organizations)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_lifecycle(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_idm.backend.assignment_lifecycle import (
|
||||||
|
SqlIdmAssignmentLifecycle,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SqlIdmAssignmentLifecycle()
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="idm",
|
id="idm",
|
||||||
name="IDM",
|
name="IDM",
|
||||||
@@ -129,6 +139,10 @@ manifest = ModuleManifest(
|
|||||||
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
version="0.1.8",
|
version="0.1.8",
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||||
|
version="0.1.8",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
@@ -162,6 +176,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: _assignment_lifecycle,
|
||||||
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
||||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
||||||
},
|
},
|
||||||
@@ -188,6 +203,7 @@ manifest = ModuleManifest(
|
|||||||
body=(
|
body=(
|
||||||
"Assignment links are high-impact because they can later feed access decisions. "
|
"Assignment links are high-impact because they can later feed access decisions. "
|
||||||
"Tenants can enable recorded change requests for assignment create and update operations. "
|
"Tenants can enable recorded change requests for assignment create and update operations. "
|
||||||
|
"A periodic worker emits one expiry event when a future-dated assignment elapses; the marker and event are committed together so retries remain idempotent. "
|
||||||
"The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write."
|
"The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
"""Track emitted assignment expiry events.
|
||||||
|
|
||||||
|
Revision ID: 9a0b1c2d3e4f
|
||||||
|
Revises: 8f9a0b1c2d3e
|
||||||
|
Create Date: 2026-07-31 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "9a0b1c2d3e4f"
|
||||||
|
down_revision = "8f9a0b1c2d3e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
["is_active", "expired_event_at", "valid_until"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
table_name="idm_organization_function_assignments",
|
||||||
|
)
|
||||||
|
op.drop_column(
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
"expired_event_at",
|
||||||
|
)
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
"""Track emitted assignment expiry events.
|
||||||
|
|
||||||
|
Revision ID: 9a0b1c2d3e4f
|
||||||
|
Revises: 8f9a0b1c2d3e
|
||||||
|
Create Date: 2026-07-31 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "9a0b1c2d3e4f"
|
||||||
|
down_revision = "8f9a0b1c2d3e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
["is_active", "expired_event_at", "valid_until"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
table_name="idm_organization_function_assignments",
|
||||||
|
)
|
||||||
|
op.drop_column(
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
"expired_event_at",
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.events import EventBus, PlatformEvent, event_bus_context
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_identity.backend.db import models as identity_models # noqa: F401
|
||||||
|
from govoplan_idm.backend.assignment_lifecycle import SqlIdmAssignmentLifecycle
|
||||||
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||||
|
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentExpiryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.database = configure_database("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.database.engine,
|
||||||
|
tables=[
|
||||||
|
IdmOrganizationFunctionAssignment.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.lifecycle = SqlIdmAssignmentLifecycle()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assignment(
|
||||||
|
assignment_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
valid_until: datetime,
|
||||||
|
active: bool = True,
|
||||||
|
expired_event_at: datetime | None = None,
|
||||||
|
) -> IdmOrganizationFunctionAssignment:
|
||||||
|
return IdmOrganizationFunctionAssignment(
|
||||||
|
id=assignment_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
identity_id=f"identity-{assignment_id}",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
valid_until=valid_until,
|
||||||
|
expired_event_at=expired_event_at,
|
||||||
|
is_active=active,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sweep_claims_due_assignments_once_and_preserves_provenance(self) -> None:
|
||||||
|
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
self._assignment(
|
||||||
|
"due",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
),
|
||||||
|
self._assignment(
|
||||||
|
"future",
|
||||||
|
valid_until=boundary + timedelta(seconds=1),
|
||||||
|
),
|
||||||
|
self._assignment(
|
||||||
|
"revoked",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
active=False,
|
||||||
|
),
|
||||||
|
self._assignment(
|
||||||
|
"already-emitted",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
expired_event_at=boundary - timedelta(minutes=1),
|
||||||
|
),
|
||||||
|
self._assignment(
|
||||||
|
"other-tenant",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
events: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("idm.function_assignment.expired.v1", events.append)
|
||||||
|
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||||
|
result = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(1, result["selected"])
|
||||||
|
self.assertEqual(1, result["expired"])
|
||||||
|
self.assertEqual(["due"], result["assignment_ids"])
|
||||||
|
self.assertEqual(1, len(events))
|
||||||
|
self.assertEqual("system", events[0].actor.type)
|
||||||
|
self.assertEqual("tenant-1", events[0].tenant.id)
|
||||||
|
self.assertEqual("identity-due", events[0].payload["identity_id"])
|
||||||
|
self.assertEqual("function-1", events[0].payload["function_id"])
|
||||||
|
|
||||||
|
with self.database.session() as session:
|
||||||
|
due = session.get(IdmOrganizationFunctionAssignment, "due")
|
||||||
|
self.assertEqual(boundary, due.expired_event_at.replace(tzinfo=timezone.utc))
|
||||||
|
repeated = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.assertEqual(0, repeated["expired"])
|
||||||
|
self.assertEqual(1, len(events))
|
||||||
|
|
||||||
|
def test_sweep_rollback_releases_marker_and_event(self) -> None:
|
||||||
|
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add(
|
||||||
|
self._assignment(
|
||||||
|
"rolled-back",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
events: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("idm.function_assignment.expired.v1", events.append)
|
||||||
|
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||||
|
self.lifecycle.process_expired(session, effective_at=boundary)
|
||||||
|
session.rollback()
|
||||||
|
self.assertEqual([], events)
|
||||||
|
|
||||||
|
with self.database.session() as session:
|
||||||
|
item = session.get(IdmOrganizationFunctionAssignment, "rolled-back")
|
||||||
|
self.assertIsNone(item.expired_event_at)
|
||||||
|
|
||||||
|
def test_limit_validation_is_bounded(self) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
for value in (0, 1001):
|
||||||
|
with self.subTest(limit=value), self.assertRaises(ValueError):
|
||||||
|
self.lifecycle.process_expired(session, limit=value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -233,6 +233,26 @@ class AssignmentWorkflowTests(unittest.TestCase):
|
|||||||
lifecycle_event_types(before, after, now=now),
|
lifecycle_event_types(before, after, now=now),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_reactivation_of_elapsed_assignment_emits_expiry(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
item = assignment(
|
||||||
|
is_active=False,
|
||||||
|
valid_until=now - timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
before = AssignmentSnapshot.from_assignment(item) # type: ignore[arg-type]
|
||||||
|
after = plan_assignment_update( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
{"is_active": True},
|
||||||
|
).after
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
"idm.function_assignment.changed.v1",
|
||||||
|
"idm.function_assignment.expired.v1",
|
||||||
|
),
|
||||||
|
lifecycle_event_types(before, after, now=now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+107
-3
@@ -14,17 +14,48 @@ from govoplan_organizations.backend.db import models as organization_models # n
|
|||||||
|
|
||||||
|
|
||||||
class StubIdentityDirectory:
|
class StubIdentityDirectory:
|
||||||
|
def __init__(self, identities: tuple[IdentityRef, ...] = ()) -> None:
|
||||||
|
self.identities = identities
|
||||||
|
|
||||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||||
return None
|
return next(
|
||||||
|
(item for item in self.identities if item.id == identity_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||||
return None
|
return next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in self.identities
|
||||||
|
if account_id in item.account_ids
|
||||||
|
or item.primary_account_id == account_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
def identities_for_accounts(self, account_ids: tuple[str, ...]) -> tuple[IdentityRef, ...]:
|
def identities_for_accounts(self, account_ids: tuple[str, ...]) -> tuple[IdentityRef, ...]:
|
||||||
return ()
|
requested = set(account_ids)
|
||||||
|
return tuple(
|
||||||
|
item
|
||||||
|
for item in self.identities
|
||||||
|
if requested.intersection(item.account_ids)
|
||||||
|
or item.primary_account_id in requested
|
||||||
|
)
|
||||||
|
|
||||||
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
|
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
|
||||||
|
identity = self.get_identity(identity_id)
|
||||||
|
if identity is None:
|
||||||
return ()
|
return ()
|
||||||
|
return tuple(
|
||||||
|
IdentityAccountLinkRef(
|
||||||
|
id=f"{identity_id}:{account_id}",
|
||||||
|
identity_id=identity_id,
|
||||||
|
account_id=account_id,
|
||||||
|
is_primary=account_id == identity.primary_account_id,
|
||||||
|
)
|
||||||
|
for account_id in identity.account_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StubOrganizationDirectory:
|
class StubOrganizationDirectory:
|
||||||
@@ -227,6 +258,79 @@ class IdmDirectoryDelegationTests(unittest.TestCase):
|
|||||||
tenant_id="tenant-2",
|
tenant_id="tenant-2",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_batch_accounts_preserve_account_and_subunit_provenance(self) -> None:
|
||||||
|
self.directory = SqlIdmDirectory(
|
||||||
|
identities=StubIdentityDirectory(
|
||||||
|
(
|
||||||
|
IdentityRef(
|
||||||
|
id="identity-shared",
|
||||||
|
primary_account_id="account-1",
|
||||||
|
account_ids=("account-1", "account-2"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
), # type: ignore[arg-type]
|
||||||
|
organizations=StubOrganizationDirectory(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
broad = IdmOrganizationFunctionAssignment(
|
||||||
|
id="broad",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-shared",
|
||||||
|
account_id=None,
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
applies_to_subunits=True,
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
account_specific = IdmOrganizationFunctionAssignment(
|
||||||
|
id="account-specific",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-shared",
|
||||||
|
account_id="account-2",
|
||||||
|
function_id="function-2",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="governance",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
simultaneous = IdmOrganizationFunctionAssignment(
|
||||||
|
id="second-incumbent",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-second",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all((broad, account_specific, simultaneous))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resolved = self.directory.organization_function_assignments_for_accounts(
|
||||||
|
("account-1", "account-2", "missing"),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(("broad",), tuple(item.id for item in resolved["account-1"]))
|
||||||
|
self.assertEqual(
|
||||||
|
("broad", "account-specific"),
|
||||||
|
tuple(item.id for item in resolved["account-2"]),
|
||||||
|
)
|
||||||
|
self.assertEqual((), resolved["missing"])
|
||||||
|
self.assertTrue(resolved["account-1"][0].applies_to_subunits)
|
||||||
|
self.assertEqual("governance", resolved["account-2"][1].source)
|
||||||
|
|
||||||
|
incumbency = self.directory.organization_function_incumbencies(
|
||||||
|
("function-1",),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)["function-1"]
|
||||||
|
self.assertEqual(
|
||||||
|
("broad", "second-incumbent"),
|
||||||
|
tuple(item.id for item in incumbency.assignments),
|
||||||
|
)
|
||||||
|
self.assertFalse(incumbency.vacant)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user