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()
+2
View File
@@ -9,6 +9,7 @@ from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.ownership import router as ownership_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions
@@ -69,6 +70,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(credential_router)
api_router.include_router(ownership_router)
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
+621
View File
@@ -0,0 +1,621 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.ownership import (
OwnershipAuthorizationError,
OwnershipIdempotencyConflict,
OwnershipResourceRef,
OwnershipSubjectRef,
OwnershipTransfer,
OwnershipTransferError,
OwnershipTransferExpired,
OwnershipTransferPolicy,
accept_ownership_transfer,
approve_administrative_recovery,
approve_ownership_request,
cancel_ownership_transfer,
decline_ownership_transfer,
execute_administrative_recovery,
request_ownership,
start_administrative_recovery,
start_owner_initiated_transfer,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_session
RECENT_AUTHENTICATION_WINDOW = timedelta(minutes=15)
class OwnershipSubjectRequest(BaseModel):
type: str = Field(min_length=1, max_length=40, pattern=r"^[a-z][a-z0-9_-]*$")
id: str = Field(min_length=1, max_length=255)
class OwnershipResourceRequest(BaseModel):
module_id: str = Field(
min_length=1,
max_length=100,
pattern=r"^[a-z][a-z0-9_-]*$",
)
resource_type: str = Field(
min_length=1,
max_length=100,
pattern=r"^[a-z][a-z0-9_-]*$",
)
resource_id: str = Field(min_length=1, max_length=255)
class OwnershipTransferStartRequest(BaseModel):
resource: OwnershipResourceRequest
target_owner: OwnershipSubjectRequest
idempotency_key: str = Field(min_length=1, max_length=200)
reason: str | None = Field(default=None, max_length=4000)
expiry_days: int | None = Field(default=None, ge=1, le=30)
class OwnershipRequestStartRequest(BaseModel):
resource: OwnershipResourceRequest
target_owner: OwnershipSubjectRequest | None = None
idempotency_key: str = Field(min_length=1, max_length=200)
reason: str | None = Field(default=None, max_length=4000)
expiry_days: int | None = Field(default=None, ge=1, le=30)
class OwnershipRecoveryStartRequest(BaseModel):
resource: OwnershipResourceRequest
target_owner: OwnershipSubjectRequest
idempotency_key: str = Field(min_length=1, max_length=200)
reason: str = Field(min_length=1, max_length=4000)
class OwnershipTransferResponse(BaseModel):
id: str
tenant_id: str
resource_module: str
resource_type: str
resource_id: str
kind: str
status: str
current_owner: OwnershipSubjectRequest
target_owner: OwnershipSubjectRequest
initiated_by: OwnershipSubjectRequest
owner_approved_by: OwnershipSubjectRequest | None = None
target_accepted_by: OwnershipSubjectRequest | None = None
reason: str | None = None
assurance_profile: str | None = None
required_approvals: int
approvals: list[dict[str, Any]] = Field(default_factory=list)
decisions: list[dict[str, Any]] = Field(default_factory=list)
expires_at: datetime
execute_after: datetime | None = None
completed_at: datetime | None = None
declined_at: datetime | None = None
cancelled_at: datetime | None = None
expired_at: datetime | None = None
revision: int
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class OwnershipTransferListResponse(BaseModel):
transfers: list[OwnershipTransferResponse] = Field(default_factory=list)
router = APIRouter(prefix="/ownership/transfers", tags=["ownership"])
@router.get("", response_model=OwnershipTransferListResponse)
def list_ownership_transfers(
request: Request,
resource_type: str | None = Query(default=None, max_length=100),
resource_id: str | None = Query(default=None, max_length=255),
transfer_status: str | None = Query(default=None, alias="status", max_length=50),
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferListResponse:
actor = _actor(principal)
query = session.query(OwnershipTransfer).filter(
OwnershipTransfer.tenant_id == principal.tenant_id
)
if resource_type:
query = query.filter(
OwnershipTransfer.resource_type == resource_type.strip().lower()
)
if resource_id:
query = query.filter(OwnershipTransfer.resource_id == resource_id)
if transfer_status:
query = query.filter(OwnershipTransfer.status == transfer_status)
rows = (
query.order_by(
OwnershipTransfer.created_at.desc(),
OwnershipTransfer.id.desc(),
)
.limit(min(limit * 5, 1000))
.all()
)
visible = [
row
for row in rows
if _can_view_transfer(request, session, row, actor)
]
return OwnershipTransferListResponse(
transfers=[_response(row) for row in visible[:limit]]
)
@router.get("/{transfer_id}", response_model=OwnershipTransferResponse)
def get_ownership_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
transfer = _visible_transfer(
request,
session,
principal,
transfer_id,
)
return _response(transfer)
@router.post(
"",
response_model=OwnershipTransferResponse,
status_code=status.HTTP_201_CREATED,
)
def create_owner_initiated_transfer(
payload: OwnershipTransferStartRequest,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _write(
session,
lambda: start_owner_initiated_transfer(
session,
tenant_id=principal.tenant_id,
resource=_resource(payload.resource),
provider=_provider(request, payload.resource),
actor=_actor(principal),
target_owner=_subject(payload.target_owner),
idempotency_key=payload.idempotency_key,
reason=payload.reason,
expiry_days=payload.expiry_days,
),
)
@router.post(
"/requests",
response_model=OwnershipTransferResponse,
status_code=status.HTTP_201_CREATED,
)
def create_ownership_request(
payload: OwnershipRequestStartRequest,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _write(
session,
lambda: request_ownership(
session,
tenant_id=principal.tenant_id,
resource=_resource(payload.resource),
provider=_provider(request, payload.resource),
actor=_actor(principal),
target_owner=(
_subject(payload.target_owner) if payload.target_owner else None
),
idempotency_key=payload.idempotency_key,
reason=payload.reason,
expiry_days=payload.expiry_days,
),
)
@router.post(
"/recoveries",
response_model=OwnershipTransferResponse,
status_code=status.HTTP_201_CREATED,
)
def create_administrative_recovery(
payload: OwnershipRecoveryStartRequest,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
_require_recovery_authority(principal)
return _write(
session,
lambda: start_administrative_recovery(
session,
tenant_id=principal.tenant_id,
resource=_resource(payload.resource),
provider=_provider(request, payload.resource),
actor=_actor(principal),
target_owner=_subject(payload.target_owner),
idempotency_key=payload.idempotency_key,
reason=payload.reason,
policy=OwnershipTransferPolicy(),
),
)
@router.post("/{transfer_id}/owner-approval", response_model=OwnershipTransferResponse)
def approve_requested_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _mutate_existing(
request,
session,
principal,
transfer_id,
approve_ownership_request,
)
@router.post("/{transfer_id}/acceptance", response_model=OwnershipTransferResponse)
def accept_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _mutate_existing(
request,
session,
principal,
transfer_id,
accept_ownership_transfer,
)
@router.post("/{transfer_id}/decline", response_model=OwnershipTransferResponse)
def decline_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _mutate_existing(
request,
session,
principal,
transfer_id,
decline_ownership_transfer,
)
@router.post("/{transfer_id}/cancel", response_model=OwnershipTransferResponse)
def cancel_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _mutate_existing(
request,
session,
principal,
transfer_id,
cancel_ownership_transfer,
)
@router.post(
"/{transfer_id}/recovery-approval",
response_model=OwnershipTransferResponse,
)
def approve_recovery(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
_require_recovery_authority(principal)
return _mutate_existing(
request,
session,
principal,
transfer_id,
approve_administrative_recovery,
)
@router.post(
"/{transfer_id}/recovery-execution",
response_model=OwnershipTransferResponse,
)
def execute_recovery(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
_require_recovery_authority(principal)
return _mutate_existing(
request,
session,
principal,
transfer_id,
execute_administrative_recovery,
)
def _mutate_existing(
request: Request,
session: Session,
principal: ApiPrincipal,
transfer_id: str,
operation,
) -> OwnershipTransferResponse:
transfer = _tenant_transfer(
session,
principal,
transfer_id,
for_update=True,
)
provider = _provider_for_transfer(request, transfer)
return _write(
session,
lambda: operation(
session,
transfer=transfer,
provider=provider,
actor=_actor(principal),
),
)
def _write(session: Session, operation) -> OwnershipTransferResponse:
try:
transfer = operation()
session.commit()
session.refresh(transfer)
return _response(transfer)
except OwnershipAuthorizationError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
) from exc
except OwnershipIdempotencyConflict as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except OwnershipTransferExpired as exc:
session.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except OwnershipTransferError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
def _tenant_transfer(
session: Session,
principal: ApiPrincipal,
transfer_id: str,
*,
for_update: bool = False,
) -> OwnershipTransfer:
query = session.query(OwnershipTransfer).filter(
OwnershipTransfer.id == transfer_id,
OwnershipTransfer.tenant_id == principal.tenant_id,
)
if for_update:
query = query.with_for_update()
transfer = query.one_or_none()
if transfer is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ownership transfer not found",
)
return transfer
def _visible_transfer(
request: Request,
session: Session,
principal: ApiPrincipal,
transfer_id: str,
) -> OwnershipTransfer:
transfer = _tenant_transfer(session, principal, transfer_id)
if not _can_view_transfer(request, session, transfer, _actor(principal)):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ownership transfer not found",
)
return transfer
def _can_view_transfer(
request: Request,
session: Session,
transfer: OwnershipTransfer,
actor: OwnershipSubjectRef,
) -> bool:
provider = _provider_for_transfer(request, transfer)
decision = provider.authorize_ownership_action(
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
action="view_transfer",
actor=actor,
current_owner=OwnershipSubjectRef(
type=transfer.current_owner_type,
id=transfer.current_owner_id,
),
target_owner=OwnershipSubjectRef(
type=transfer.target_owner_type,
id=transfer.target_owner_id,
),
)
return decision.allowed
def _provider(request: Request, resource: OwnershipResourceRequest):
registry = _registry(request)
registration = registry.ownership_provider_registration(resource.resource_type)
if (
registration is None
or registration.module_id != resource.module_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Owned resource type is not available",
)
return registration.provider
def _provider_for_transfer(request: Request, transfer: OwnershipTransfer):
return _provider(
request,
OwnershipResourceRequest(
module_id=transfer.resource_module,
resource_type=transfer.resource_type,
resource_id=transfer.resource_id,
),
)
def _registry(request: Request) -> PlatformRegistry:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Platform registry is not available",
)
return registry
def _actor(principal: ApiPrincipal) -> OwnershipSubjectRef:
actor_id = principal.membership_id
if not actor_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Ownership actions require a tenant membership",
)
return OwnershipSubjectRef(
type="user",
id=actor_id,
label=principal.display_name,
scopes=principal.scopes,
group_ids=principal.group_ids,
recently_authenticated=_recently_authenticated(principal),
)
def _recently_authenticated(principal: ApiPrincipal) -> bool:
auth_session = principal.auth_session
created_at = getattr(auth_session, "created_at", None)
if not isinstance(created_at, datetime):
return False
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
elapsed = datetime.now(timezone.utc) - created_at
return timedelta(0) <= elapsed <= RECENT_AUTHENTICATION_WINDOW
def _require_recovery_authority(principal: ApiPrincipal) -> None:
if not (
principal.has("admin:settings:write")
or principal.has("system:settings:write")
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Administrative ownership recovery authority is required",
)
def _resource(payload: OwnershipResourceRequest) -> OwnershipResourceRef:
return OwnershipResourceRef(
module_id=payload.module_id,
resource_type=payload.resource_type,
resource_id=payload.resource_id,
)
def _subject(payload: OwnershipSubjectRequest) -> OwnershipSubjectRef:
return OwnershipSubjectRef(type=payload.type, id=payload.id)
def _optional_subject(
subject_type: str | None,
subject_id: str | None,
) -> OwnershipSubjectRequest | None:
if not subject_type or not subject_id:
return None
return OwnershipSubjectRequest(type=subject_type, id=subject_id)
def _response(transfer: OwnershipTransfer) -> OwnershipTransferResponse:
return OwnershipTransferResponse(
id=transfer.id,
tenant_id=transfer.tenant_id,
resource_module=transfer.resource_module,
resource_type=transfer.resource_type,
resource_id=transfer.resource_id,
kind=transfer.kind,
status=transfer.status,
current_owner=OwnershipSubjectRequest(
type=transfer.current_owner_type,
id=transfer.current_owner_id,
),
target_owner=OwnershipSubjectRequest(
type=transfer.target_owner_type,
id=transfer.target_owner_id,
),
initiated_by=OwnershipSubjectRequest(
type=transfer.initiated_by_type,
id=transfer.initiated_by_id,
),
owner_approved_by=_optional_subject(
transfer.owner_approved_by_type,
transfer.owner_approved_by_id,
),
target_accepted_by=_optional_subject(
transfer.target_accepted_by_type,
transfer.target_accepted_by_id,
),
reason=transfer.reason,
assurance_profile=transfer.assurance_profile,
required_approvals=transfer.required_approvals,
approvals=list(transfer.approvals or []),
decisions=list(transfer.decisions or []),
expires_at=transfer.expires_at,
execute_after=transfer.execute_after,
completed_at=transfer.completed_at,
declined_at=transfer.declined_at,
cancelled_at=transfer.cancelled_at,
expired_at=transfer.expired_at,
revision=transfer.revision,
metadata=dict(transfer.metadata_ or {}),
created_at=transfer.created_at,
updated_at=transfer.updated_at,
)
__all__ = ["router"]