Complete effective assignment expiry lifecycle

This commit is contained in:
2026-07-31 18:07:36 +02:00
parent d1c5738ca8
commit f025b0c25b
13 changed files with 619 additions and 59 deletions
+27 -50
View File
@@ -16,13 +16,6 @@ from govoplan_core.core.configuration_control import (
ensure_configuration_change_allowed,
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.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
@@ -42,11 +35,13 @@ from govoplan_core.security.time import utc_now
from govoplan_idm.backend.assignment_transitions import (
AssignmentMutationPlan,
AssignmentTransitionError,
assignment_is_expired,
lifecycle_event_types,
plan_assignment_update,
validate_assignment_shape,
validate_assignment_source_rules,
)
from govoplan_idm.backend.assignment_events import emit_assignment_event
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from .schemas import (
@@ -436,45 +431,12 @@ def _publish_assignment_event(
*,
event_type: str,
) -> None:
emit_platform_event(
emit_assignment_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,
"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",
)
item,
event_type=event_type,
actor_type="account",
actor_id=principal.account_id,
)
@@ -621,6 +583,15 @@ def create_organization_function_assignment(
result,
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)
return _assignment_item(item)
@@ -643,6 +614,16 @@ def update_organization_function_assignment(
tenant_id=tenant_id,
)
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)
result = _assignment_item(item)
after = result.model_dump(mode="json")
@@ -656,11 +637,7 @@ def update_organization_function_assignment(
before=before,
after=after,
)
for event_type in lifecycle_event_types(
plan.before,
plan.after,
now=utc_now(),
):
for event_type in event_types:
_publish_assignment_event(
session,
principal,
@@ -37,6 +37,7 @@ class OrganizationFunctionAssignmentItem(BaseModel):
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
expired_event_at: datetime | None = None
is_active: bool
settings: dict[str, Any]
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"]
if before.is_active and not after.is_active:
events.append("idm.function_assignment.revoked.v1")
if (
after.valid_until is not None
and _comparable_datetime(after.valid_until) <= _comparable_datetime(now)
and before.valid_until != after.valid_until
before_expired = _is_expired(before, now=now)
after_expired = _is_expired(after, now=now)
if after_expired and (
not before_expired or before.valid_until != after.valid_until
):
events.append("idm.function_assignment.expired.v1")
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(
item: AssignmentLike,
*,
@@ -288,6 +308,7 @@ __all__ = [
"AssignmentMutationPlan",
"AssignmentSnapshot",
"AssignmentTransitionError",
"assignment_is_expired",
"lifecycle_event_types",
"plan_assignment_update",
"validate_assignment_shape",
+8 -1
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
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 govoplan_core.db.base import Base, TimestampMixin
@@ -24,6 +24,12 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
"organization_unit_id",
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)
@@ -38,6 +44,7 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
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_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)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
+16
View File
@@ -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.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH, IdentityDirectory
from govoplan_core.core.idm import (
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
)
@@ -111,6 +112,15 @@ def _idm_directory(context: ModuleContext) -> object:
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(
id="idm",
name="IDM",
@@ -129,6 +139,10 @@ manifest = ModuleManifest(
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
version="0.1.8",
),
ModuleInterfaceProvider(
name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
version="0.1.8",
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -162,6 +176,7 @@ manifest = ModuleManifest(
),
),
capability_factories={
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: _assignment_lifecycle,
CAPABILITY_IDM_DIRECTORY: _idm_directory,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
},
@@ -188,6 +203,7 @@ manifest = ModuleManifest(
body=(
"Assignment links are high-impact because they can later feed access decisions. "
"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."
),
layer="configured",
@@ -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,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",
)