feat: add governed ownership workflows and admin tree navigation

This commit is contained in:
2026-07-30 17:42:05 +02:00
parent 9b88ae388b
commit f0898fcdee
17 changed files with 2857 additions and 1 deletions
+48
View File
@@ -1,10 +1,40 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
@dataclass(frozen=True, slots=True)
class NotificationMailDeliveryRequest:
tenant_id: str
notification_id: str
recipient: str
subject: str
body_text: str
body_html: str | None = None
action_url: str | None = None
mail_profile_id: str | None = None
from_address: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class NotificationMailDeliveryProvider(Protocol):
"""Mail-owned durable submission boundary for notification email."""
def submit_notification_mail(
self,
session: object,
request: NotificationMailDeliveryRequest,
) -> Mapping[str, object]:
...
@runtime_checkable
@@ -28,3 +58,21 @@ class MailDeliveryOutboxProvider(Protocol):
limit: int = 250,
) -> Mapping[str, object]:
...
def notification_mail_delivery_provider(
registry: object | None,
) -> NotificationMailDeliveryProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
if not isinstance(provider, NotificationMailDeliveryProvider):
raise TypeError(
"mail.notificationDelivery provider does not implement "
"NotificationMailDeliveryProvider"
)
return provider
+2
View File
@@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING:
@@ -405,6 +406,7 @@ class ModuleManifest:
nav_items: tuple[NavItem, ...] = ()
frontend: FrontendModule | None = None
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
+965
View File
@@ -0,0 +1,965 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import json
from typing import Any, Protocol, runtime_checkable
import uuid
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.db.base import Base, TimestampMixin, utcnow
class OwnershipTransferKind(StrEnum):
OWNER_INITIATED = "owner_initiated"
TARGET_REQUESTED = "target_requested"
ADMINISTRATIVE_RECOVERY = "administrative_recovery"
class OwnershipTransferStatus(StrEnum):
AWAITING_OWNER_APPROVAL = "awaiting_owner_approval"
AWAITING_TARGET_ACCEPTANCE = "awaiting_target_acceptance"
AWAITING_RECOVERY_APPROVALS = "awaiting_recovery_approvals"
RECOVERY_SCHEDULED = "recovery_scheduled"
COMPLETED = "completed"
DECLINED = "declined"
CANCELLED = "cancelled"
EXPIRED = "expired"
TERMINAL_OWNERSHIP_TRANSFER_STATUSES = frozenset(
{
OwnershipTransferStatus.COMPLETED.value,
OwnershipTransferStatus.DECLINED.value,
OwnershipTransferStatus.CANCELLED.value,
OwnershipTransferStatus.EXPIRED.value,
}
)
class OwnershipTransferError(ValueError):
pass
class OwnershipAuthorizationError(PermissionError):
pass
class OwnershipIdempotencyConflict(OwnershipTransferError):
pass
class OwnershipTransferExpired(OwnershipTransferError):
pass
@dataclass(frozen=True, slots=True)
class OwnershipSubjectRef:
type: str
id: str
label: str | None = None
scopes: frozenset[str] = frozenset()
group_ids: frozenset[str] = frozenset()
recently_authenticated: bool = False
def __post_init__(self) -> None:
if not self.type.strip() or not self.id.strip():
raise ValueError("Ownership subjects require a type and id")
@dataclass(frozen=True, slots=True)
class OwnershipResourceRef:
module_id: str
resource_type: str
resource_id: str
def __post_init__(self) -> None:
if not self.module_id.strip() or not self.resource_type.strip() or not self.resource_id.strip():
raise ValueError("Ownership resources require module, type, and id")
@dataclass(frozen=True, slots=True)
class OwnershipActionDecision:
allowed: bool
reason: str | None = None
requirements: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OwnershipTransferPolicy:
default_expiry_days: int = 7
min_expiry_days: int = 1
max_expiry_days: int = 30
recovery_assurance_profile: str = "standard"
recovery_required_approvals: int = 2
recovery_delay_hours: int = 24
recent_authentication_required: bool = True
def __post_init__(self) -> None:
if not 1 <= self.min_expiry_days <= self.default_expiry_days <= self.max_expiry_days:
raise ValueError("Ownership transfer expiry policy is inconsistent")
if self.recovery_required_approvals < 1:
raise ValueError("Ownership recovery requires at least one approval")
if self.recovery_delay_hours < 0:
raise ValueError("Ownership recovery delay cannot be negative")
@classmethod
def development(cls) -> OwnershipTransferPolicy:
return cls(
recovery_assurance_profile="development-single-admin",
recovery_required_approvals=1,
recovery_delay_hours=0,
recent_authentication_required=False,
)
@runtime_checkable
class ResourceOwnershipProvider(Protocol):
def current_owner(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
) -> OwnershipSubjectRef | None:
...
def authorize_ownership_action(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
action: str,
actor: OwnershipSubjectRef,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
) -> OwnershipActionDecision:
...
def apply_owner(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
expected_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
reason: str | None,
) -> None:
...
@dataclass(frozen=True, slots=True)
class OwnershipProviderRegistration:
resource_type: str
provider: ResourceOwnershipProvider
module_id: str | None = None
class OwnershipTransfer(Base, TimestampMixin):
__tablename__ = "core_ownership_transfers"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"resource_module",
"idempotency_key",
name="uq_core_ownership_transfer_idempotency",
),
Index(
"ix_core_ownership_transfer_resource",
"tenant_id",
"resource_module",
"resource_type",
"resource_id",
"status",
),
Index(
"ix_core_ownership_transfer_expiry",
"status",
"expires_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
resource_module: Mapped[str] = mapped_column(String(100), nullable=False)
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
resource_id: Mapped[str] = mapped_column(String(255), nullable=False)
kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
current_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
current_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
target_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
target_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
initiated_by_type: Mapped[str] = mapped_column(String(40), nullable=False)
initiated_by_id: Mapped[str] = mapped_column(String(255), nullable=False)
owner_approved_by_type: Mapped[str | None] = mapped_column(String(40))
owner_approved_by_id: Mapped[str | None] = mapped_column(String(255))
target_accepted_by_type: Mapped[str | None] = mapped_column(String(40))
target_accepted_by_id: Mapped[str | None] = mapped_column(String(255))
reason: Mapped[str | None] = mapped_column(Text)
assurance_profile: Mapped[str | None] = mapped_column(String(80))
required_approvals: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
approvals: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
execute_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
def start_owner_initiated_transfer(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
idempotency_key: str,
reason: str | None = None,
expiry_days: int | None = None,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
return _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=target_owner,
kind=OwnershipTransferKind.OWNER_INITIATED,
initial_status=OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE,
authorization_action="propose_transfer",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=expiry_days,
policy=policy,
now=now,
assurance_profile=None,
required_approvals=1,
execute_after=None,
metadata={},
)
def request_ownership(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef | None = None,
idempotency_key: str,
reason: str | None = None,
expiry_days: int | None = None,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
requested_owner = target_owner or actor
return _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=requested_owner,
kind=OwnershipTransferKind.TARGET_REQUESTED,
initial_status=OwnershipTransferStatus.AWAITING_OWNER_APPROVAL,
authorization_action="request_ownership",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=expiry_days,
policy=policy,
now=now,
assurance_profile=None,
required_approvals=1,
execute_after=None,
metadata={},
)
def approve_ownership_request(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
if (
transfer.kind != OwnershipTransferKind.TARGET_REQUESTED.value
or transfer.status != OwnershipTransferStatus.AWAITING_OWNER_APPROVAL.value
):
raise OwnershipTransferError("Ownership request is not awaiting owner approval")
_authorize(provider, session, transfer, actor, "approve_requested_transfer")
transfer.owner_approved_by_type = actor.type
transfer.owner_approved_by_id = actor.id
transfer.status = OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value
_touch(transfer)
_record_decision(
transfer,
action="owner_approved",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "owner_approved", actor)
session.flush()
return transfer
def accept_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
if transfer.status != OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value:
raise OwnershipTransferError("Ownership transfer is not awaiting target acceptance")
action = (
"accept_group_transfer"
if transfer.target_owner_type == "group"
else "accept_transfer"
)
_authorize(provider, session, transfer, actor, action)
current_owner = _owner_ref(transfer, target=False)
target_owner = _owner_ref(transfer, target=True)
provider.apply_owner(
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
expected_owner=current_owner,
target_owner=target_owner,
actor=actor,
reason=transfer.reason,
)
transfer.target_accepted_by_type = actor.type
transfer.target_accepted_by_id = actor.id
transfer.status = OwnershipTransferStatus.COMPLETED.value
transfer.completed_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="accepted",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "completed", actor)
session.flush()
return transfer
def decline_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_authorize(provider, session, transfer, actor, "decline_transfer")
transfer.status = OwnershipTransferStatus.DECLINED.value
transfer.declined_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="declined",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "declined", actor)
session.flush()
return transfer
def cancel_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_authorize(provider, session, transfer, actor, "cancel_transfer")
transfer.status = OwnershipTransferStatus.CANCELLED.value
transfer.cancelled_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="cancelled",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "cancelled", actor)
session.flush()
return transfer
def start_administrative_recovery(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
idempotency_key: str,
reason: str,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
if not reason.strip():
raise OwnershipTransferError("Administrative recovery requires a reason")
if policy.recent_authentication_required and not actor.recently_authenticated:
raise OwnershipAuthorizationError(
"Administrative recovery requires recent authentication"
)
effective_now = _utc(now)
transfer = _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=target_owner,
kind=OwnershipTransferKind.ADMINISTRATIVE_RECOVERY,
initial_status=OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS,
authorization_action="request_recovery",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=policy.default_expiry_days,
policy=policy,
now=effective_now,
assurance_profile=policy.recovery_assurance_profile,
required_approvals=policy.recovery_required_approvals,
execute_after=effective_now
+ timedelta(hours=policy.recovery_delay_hours),
metadata={
"recent_authentication_required": (
policy.recent_authentication_required
),
"encryption_keys_included": False,
},
)
return transfer
def approve_administrative_recovery(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_require_recovery_authentication(transfer, actor)
if (
transfer.kind != OwnershipTransferKind.ADMINISTRATIVE_RECOVERY.value
or transfer.status
not in {
OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS.value,
OwnershipTransferStatus.RECOVERY_SCHEDULED.value,
}
):
raise OwnershipTransferError("Ownership recovery is not awaiting approval")
_authorize(provider, session, transfer, actor, "approve_recovery")
approvals = list(transfer.approvals or [])
if any(
item.get("actor_type") == actor.type and item.get("actor_id") == actor.id
for item in approvals
):
return transfer
approvals.append(
{
"actor_type": actor.type,
"actor_id": actor.id,
"approved_at": effective_now.isoformat(),
}
)
transfer.approvals = approvals
if len(approvals) >= transfer.required_approvals:
transfer.status = OwnershipTransferStatus.RECOVERY_SCHEDULED.value
_touch(transfer)
_record_decision(
transfer,
action="recovery_approved",
actor=actor,
decided_at=effective_now,
details={"approval_count": len(approvals)},
)
_emit_transfer_event(session, transfer, "recovery_approved", actor)
session.flush()
return transfer
def execute_administrative_recovery(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_require_recovery_authentication(transfer, actor)
if transfer.status != OwnershipTransferStatus.RECOVERY_SCHEDULED.value:
raise OwnershipTransferError("Ownership recovery has not reached its approval quorum")
if transfer.execute_after and _utc(transfer.execute_after) > effective_now:
raise OwnershipTransferError("Ownership recovery assurance delay has not elapsed")
_authorize(provider, session, transfer, actor, "execute_recovery")
provider.apply_owner(
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
expected_owner=_owner_ref(transfer, target=False),
target_owner=_owner_ref(transfer, target=True),
actor=actor,
reason=transfer.reason,
)
transfer.status = OwnershipTransferStatus.COMPLETED.value
transfer.completed_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="recovery_executed",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "recovery_completed", actor)
session.flush()
return transfer
def expire_due_ownership_transfers(
session: Session,
*,
now: datetime | None = None,
limit: int = 250,
) -> int:
effective_now = _utc(now)
rows = (
session.query(OwnershipTransfer)
.filter(
OwnershipTransfer.status.notin_(
sorted(TERMINAL_OWNERSHIP_TRANSFER_STATUSES)
),
OwnershipTransfer.expires_at <= effective_now,
)
.order_by(OwnershipTransfer.expires_at.asc(), OwnershipTransfer.id.asc())
.limit(limit)
.all()
)
for transfer in rows:
transfer.status = OwnershipTransferStatus.EXPIRED.value
transfer.expired_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="expired",
actor=None,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "expired", None)
session.flush()
return len(rows)
def _start_transfer(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
kind: OwnershipTransferKind,
initial_status: OwnershipTransferStatus,
authorization_action: str,
idempotency_key: str,
reason: str | None,
expiry_days: int | None,
policy: OwnershipTransferPolicy,
now: datetime | None,
assurance_profile: str | None,
required_approvals: int,
execute_after: datetime | None,
metadata: dict[str, Any],
) -> OwnershipTransfer:
if not resource.module_id or resource.module_id != resource.module_id.strip():
raise OwnershipTransferError("Ownership resource module id is invalid")
clean_key = idempotency_key.strip()
if not clean_key or len(clean_key) > 200:
raise OwnershipTransferError("A bounded ownership idempotency key is required")
effective_now = _utc(now)
days = policy.default_expiry_days if expiry_days is None else expiry_days
if not policy.min_expiry_days <= days <= policy.max_expiry_days:
raise OwnershipTransferError(
"Ownership transfer expiry is outside the effective policy"
)
current_owner = provider.current_owner(
session,
tenant_id=tenant_id,
resource_id=resource.resource_id,
)
if current_owner is None:
raise OwnershipTransferError("Owned resource was not found")
if current_owner.type == target_owner.type and current_owner.id == target_owner.id:
raise OwnershipTransferError("Target is already the resource owner")
_authorize_direct(
provider,
session,
tenant_id=tenant_id,
resource_id=resource.resource_id,
current_owner=current_owner,
target_owner=target_owner,
actor=actor,
action=authorization_action,
)
request_hash = _request_hash(
tenant_id=tenant_id,
resource=resource,
kind=kind,
current_owner=current_owner,
target_owner=target_owner,
actor=actor,
reason=reason,
expiry_days=days,
assurance_profile=assurance_profile,
required_approvals=required_approvals,
execute_after=execute_after,
metadata=metadata,
)
existing = (
session.query(OwnershipTransfer)
.filter(
OwnershipTransfer.tenant_id == tenant_id,
OwnershipTransfer.resource_module == resource.module_id,
OwnershipTransfer.idempotency_key == clean_key,
)
.one_or_none()
)
if existing is not None:
if existing.canonical_request_hash != request_hash:
raise OwnershipIdempotencyConflict(
"Ownership idempotency key is already bound to another request"
)
return existing
transfer = OwnershipTransfer(
tenant_id=tenant_id,
resource_module=resource.module_id,
resource_type=resource.resource_type,
resource_id=resource.resource_id,
kind=kind.value,
status=initial_status.value,
current_owner_type=current_owner.type,
current_owner_id=current_owner.id,
target_owner_type=target_owner.type,
target_owner_id=target_owner.id,
initiated_by_type=actor.type,
initiated_by_id=actor.id,
reason=reason.strip() if reason else None,
assurance_profile=assurance_profile,
required_approvals=required_approvals,
approvals=[],
decisions=[],
idempotency_key=clean_key,
canonical_request_hash=request_hash,
expires_at=effective_now + timedelta(days=days),
execute_after=execute_after,
metadata_=dict(metadata),
)
session.add(transfer)
session.flush()
_record_decision(
transfer,
action="started",
actor=actor,
decided_at=effective_now,
details={
"kind": kind.value,
"initial_status": initial_status.value,
"assurance_profile": assurance_profile,
"required_approvals": required_approvals,
},
)
_emit_transfer_event(session, transfer, "started", actor)
return transfer
def _authorize(
provider: ResourceOwnershipProvider,
session: Session,
transfer: OwnershipTransfer,
actor: OwnershipSubjectRef,
action: str,
) -> None:
_authorize_direct(
provider,
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
current_owner=_owner_ref(transfer, target=False),
target_owner=_owner_ref(transfer, target=True),
actor=actor,
action=action,
)
def _authorize_direct(
provider: ResourceOwnershipProvider,
session: Session,
*,
tenant_id: str,
resource_id: str,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
action: str,
) -> None:
decision = provider.authorize_ownership_action(
session,
tenant_id=tenant_id,
resource_id=resource_id,
action=action,
actor=actor,
current_owner=current_owner,
target_owner=target_owner,
)
if not decision.allowed:
raise OwnershipAuthorizationError(
decision.reason or f"Ownership action is not allowed: {action}"
)
def _require_active(
session: Session,
transfer: OwnershipTransfer,
now: datetime,
) -> None:
if transfer.status in TERMINAL_OWNERSHIP_TRANSFER_STATUSES:
raise OwnershipTransferError("Ownership transfer is already final")
if _utc(transfer.expires_at) <= now:
transfer.status = OwnershipTransferStatus.EXPIRED.value
transfer.expired_at = now
_touch(transfer)
_record_decision(
transfer,
action="expired",
actor=None,
decided_at=now,
)
_emit_transfer_event(session, transfer, "expired", None)
raise OwnershipTransferExpired("Ownership transfer has expired")
def _require_recovery_authentication(
transfer: OwnershipTransfer,
actor: OwnershipSubjectRef,
) -> None:
if (
bool((transfer.metadata_ or {}).get("recent_authentication_required"))
and not actor.recently_authenticated
):
raise OwnershipAuthorizationError(
"Administrative recovery requires recent authentication"
)
def _owner_ref(
transfer: OwnershipTransfer,
*,
target: bool,
) -> OwnershipSubjectRef:
return OwnershipSubjectRef(
type=transfer.target_owner_type if target else transfer.current_owner_type,
id=transfer.target_owner_id if target else transfer.current_owner_id,
)
def _touch(transfer: OwnershipTransfer) -> None:
transfer.revision = int(transfer.revision or 0) + 1
def _record_decision(
transfer: OwnershipTransfer,
*,
action: str,
actor: OwnershipSubjectRef | None,
decided_at: datetime,
details: dict[str, Any] | None = None,
) -> None:
decisions = list(transfer.decisions or [])
decisions.append(
{
"sequence": len(decisions) + 1,
"action": action,
"actor_type": actor.type if actor else None,
"actor_id": actor.id if actor else None,
"decided_at": _utc(decided_at).isoformat(),
"status": transfer.status,
"details": dict(details or {}),
}
)
transfer.decisions = decisions
def _utc(value: datetime | None) -> datetime:
if value is None:
return utcnow()
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _request_hash(
*,
tenant_id: str,
resource: OwnershipResourceRef,
kind: OwnershipTransferKind,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
reason: str | None,
expiry_days: int,
assurance_profile: str | None,
required_approvals: int,
execute_after: datetime | None,
metadata: dict[str, Any],
) -> str:
payload = {
"tenant_id": tenant_id,
"resource": {
"module_id": resource.module_id,
"type": resource.resource_type,
"id": resource.resource_id,
},
"kind": kind.value,
"current_owner": {"type": current_owner.type, "id": current_owner.id},
"target_owner": {"type": target_owner.type, "id": target_owner.id},
"actor": {"type": actor.type, "id": actor.id},
"reason": reason.strip() if reason else None,
"expiry_days": expiry_days,
"assurance_profile": assurance_profile,
"required_approvals": required_approvals,
"execute_after": (
_utc(execute_after).isoformat()
if execute_after is not None
else None
),
"metadata": metadata,
}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _emit_transfer_event(
session: Session,
transfer: OwnershipTransfer,
action: str,
actor: OwnershipSubjectRef | None,
) -> None:
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
emit_platform_event(
session,
PlatformEvent(
type=f"core.ownership_transfer.{action}.v1",
module_id="core",
tenant=EventTenantRef(id=transfer.tenant_id),
actor=EventActorRef(type=actor.type, id=actor.id) if actor else None,
resource=EventObjectRef(
type="ownership_transfer",
id=transfer.id,
),
classification="confidential",
payload={
"schema_version": 1,
"resource_module": transfer.resource_module,
"resource_type": transfer.resource_type,
"resource_id": transfer.resource_id,
"kind": transfer.kind,
"status": transfer.status,
"current_owner_type": transfer.current_owner_type,
"current_owner_id": transfer.current_owner_id,
"target_owner_type": transfer.target_owner_type,
"target_owner_id": transfer.target_owner_id,
"assurance_profile": transfer.assurance_profile,
"required_approvals": transfer.required_approvals,
"approval_count": len(transfer.approvals or []),
"encryption_keys_included": False,
},
),
)
__all__ = [
"OwnershipActionDecision",
"OwnershipAuthorizationError",
"OwnershipIdempotencyConflict",
"OwnershipProviderRegistration",
"OwnershipResourceRef",
"OwnershipSubjectRef",
"OwnershipTransfer",
"OwnershipTransferError",
"OwnershipTransferExpired",
"OwnershipTransferKind",
"OwnershipTransferPolicy",
"OwnershipTransferStatus",
"ResourceOwnershipProvider",
"accept_ownership_transfer",
"approve_administrative_recovery",
"approve_ownership_request",
"cancel_ownership_transfer",
"decline_ownership_transfer",
"execute_administrative_recovery",
"expire_due_ownership_transfers",
"request_ownership",
"start_administrative_recovery",
"start_owner_initiated_transfer",
]
+46
View File
@@ -25,6 +25,10 @@ from govoplan_core.core.modules import (
TenantSummaryProvider,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.ownership import (
OwnershipProviderRegistration,
ResourceOwnershipProvider,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import (
RegisteredSearchProvider,
@@ -65,6 +69,7 @@ class PlatformRegistry:
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None
@@ -86,6 +91,8 @@ class PlatformRegistry:
for resource_type, providers in manifest.delete_veto_providers.items():
for provider in providers:
self.register_delete_veto(manifest.id, resource_type, provider)
for registration in manifest.ownership_providers:
self.register_ownership_provider(manifest.id, registration)
for name, factory in manifest.capability_factories.items():
self.register_capability_factory(manifest.id, name, factory)
for registration in manifest.search_providers:
@@ -121,6 +128,7 @@ class PlatformRegistry:
resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items()
})
self._ownership_providers = dict(replacement._ownership_providers)
self._capability_factories = dict(replacement._capability_factories)
self._search_provider_registrations = list(
replacement._search_provider_registrations
@@ -171,6 +179,44 @@ class PlatformRegistry:
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
def register_ownership_provider(
self,
module_id: str,
registration: OwnershipProviderRegistration,
) -> None:
resource_type = registration.resource_type.strip().lower()
if not resource_type:
raise RegistryError(
f"Ownership provider in {module_id} has no resource type"
)
if resource_type in self._ownership_providers:
raise RegistryError(
f"Duplicate ownership provider for resource type: {resource_type}"
)
if not isinstance(registration.provider, ResourceOwnershipProvider):
raise RegistryError(
f"Ownership provider for {resource_type} does not implement "
"ResourceOwnershipProvider"
)
self._ownership_providers[resource_type] = OwnershipProviderRegistration(
resource_type=resource_type,
provider=registration.provider,
module_id=module_id,
)
def ownership_provider(
self,
resource_type: str,
) -> ResourceOwnershipProvider | None:
registration = self.ownership_provider_registration(resource_type)
return registration.provider if registration else None
def ownership_provider_registration(
self,
resource_type: str,
) -> OwnershipProviderRegistration | None:
return self._ownership_providers.get(resource_type.strip().lower())
def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context
self._capabilities.clear()