Complete effective assignment expiry lifecycle
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+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",
|
||||
)
|
||||
Reference in New Issue
Block a user