|
|
|
@@ -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",
|
|
|
|
|
]
|