feat: add governed ownership workflows and admin tree navigation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user