feat: add governed ownership workflows and admin tree navigation
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
"""development-track wrapper for generic ownership transfers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_path = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "versions"
|
||||||
|
/ "d03a7b9c1e5f_core_ownership_transfers.py"
|
||||||
|
)
|
||||||
|
_spec = spec_from_file_location("govoplan_core_ownership_transfer_migration", _path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Unable to load ownership migration from {_path}")
|
||||||
|
_migration = module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_migration)
|
||||||
|
|
||||||
|
revision = _migration.revision
|
||||||
|
down_revision = _migration.down_revision
|
||||||
|
branch_labels = _migration.branch_labels
|
||||||
|
depends_on = _migration.depends_on
|
||||||
|
upgrade = _migration.upgrade
|
||||||
|
downgrade = _migration.downgrade
|
||||||
@@ -12,6 +12,7 @@ except ModuleNotFoundError as exc:
|
|||||||
raise
|
raise
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
|
||||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||||
from govoplan_core.core.migrations import migration_metadata_plan
|
from govoplan_core.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|||||||
129
alembic/versions/d03a7b9c1e5f_core_ownership_transfers.py
Normal file
129
alembic/versions/d03a7b9c1e5f_core_ownership_transfers.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""add generic resource ownership transfer state
|
||||||
|
|
||||||
|
Revision ID: d03a7b9c1e5f
|
||||||
|
Revises: c91f0a72be34
|
||||||
|
Create Date: 2026-07-30 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "d03a7b9c1e5f"
|
||||||
|
down_revision = "c91f0a72be34"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_ownership_transfers" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"core_ownership_transfers",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("resource_module", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_type", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("current_owner_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("current_owner_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("target_owner_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("target_owner_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("initiated_by_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("initiated_by_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("owner_approved_by_type", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("owner_approved_by_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("target_accepted_by_type", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("target_accepted_by_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("assurance_profile", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("required_approvals", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("approvals", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("decisions", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("execute_after", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_ownership_transfers")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"resource_module",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_core_ownership_transfer_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_ownership_transfers_tenant_id"),
|
||||||
|
"core_ownership_transfers",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_ownership_transfers_kind"),
|
||||||
|
"core_ownership_transfers",
|
||||||
|
["kind"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_ownership_transfers_status"),
|
||||||
|
"core_ownership_transfers",
|
||||||
|
["status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_ownership_transfer_resource",
|
||||||
|
"core_ownership_transfers",
|
||||||
|
[
|
||||||
|
"tenant_id",
|
||||||
|
"resource_module",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"status",
|
||||||
|
],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_ownership_transfer_expiry",
|
||||||
|
"core_ownership_transfers",
|
||||||
|
["status", "expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_ownership_transfers" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.drop_index(
|
||||||
|
"ix_core_ownership_transfer_expiry",
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_core_ownership_transfer_resource",
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_core_ownership_transfers_status"),
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_core_ownership_transfers_kind"),
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_core_ownership_transfers_tenant_id"),
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_table("core_ownership_transfers")
|
||||||
@@ -1,10 +1,40 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
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
|
@runtime_checkable
|
||||||
@@ -28,3 +58,21 @@ class MailDeliveryOutboxProvider(Protocol):
|
|||||||
limit: int = 250,
|
limit: int = 250,
|
||||||
) -> Mapping[str, object]:
|
) -> 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 dataclasses import dataclass, field
|
||||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||||
|
|
||||||
|
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -405,6 +406,7 @@ class ModuleManifest:
|
|||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
frontend: FrontendModule | None = None
|
frontend: FrontendModule | None = None
|
||||||
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
||||||
|
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
|
||||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||||
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||||
|
|||||||
965
src/govoplan_core/core/ownership.py
Normal file
965
src/govoplan_core/core/ownership.py
Normal file
@@ -0,0 +1,965 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferKind(StrEnum):
|
||||||
|
OWNER_INITIATED = "owner_initiated"
|
||||||
|
TARGET_REQUESTED = "target_requested"
|
||||||
|
ADMINISTRATIVE_RECOVERY = "administrative_recovery"
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferStatus(StrEnum):
|
||||||
|
AWAITING_OWNER_APPROVAL = "awaiting_owner_approval"
|
||||||
|
AWAITING_TARGET_ACCEPTANCE = "awaiting_target_acceptance"
|
||||||
|
AWAITING_RECOVERY_APPROVALS = "awaiting_recovery_approvals"
|
||||||
|
RECOVERY_SCHEDULED = "recovery_scheduled"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
DECLINED = "declined"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_OWNERSHIP_TRANSFER_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
OwnershipTransferStatus.COMPLETED.value,
|
||||||
|
OwnershipTransferStatus.DECLINED.value,
|
||||||
|
OwnershipTransferStatus.CANCELLED.value,
|
||||||
|
OwnershipTransferStatus.EXPIRED.value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipAuthorizationError(PermissionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipIdempotencyConflict(OwnershipTransferError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferExpired(OwnershipTransferError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipSubjectRef:
|
||||||
|
type: str
|
||||||
|
id: str
|
||||||
|
label: str | None = None
|
||||||
|
scopes: frozenset[str] = frozenset()
|
||||||
|
group_ids: frozenset[str] = frozenset()
|
||||||
|
recently_authenticated: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.type.strip() or not self.id.strip():
|
||||||
|
raise ValueError("Ownership subjects require a type and id")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipResourceRef:
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.module_id.strip() or not self.resource_type.strip() or not self.resource_id.strip():
|
||||||
|
raise ValueError("Ownership resources require module, type, and id")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipActionDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str | None = None
|
||||||
|
requirements: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipTransferPolicy:
|
||||||
|
default_expiry_days: int = 7
|
||||||
|
min_expiry_days: int = 1
|
||||||
|
max_expiry_days: int = 30
|
||||||
|
recovery_assurance_profile: str = "standard"
|
||||||
|
recovery_required_approvals: int = 2
|
||||||
|
recovery_delay_hours: int = 24
|
||||||
|
recent_authentication_required: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= self.min_expiry_days <= self.default_expiry_days <= self.max_expiry_days:
|
||||||
|
raise ValueError("Ownership transfer expiry policy is inconsistent")
|
||||||
|
if self.recovery_required_approvals < 1:
|
||||||
|
raise ValueError("Ownership recovery requires at least one approval")
|
||||||
|
if self.recovery_delay_hours < 0:
|
||||||
|
raise ValueError("Ownership recovery delay cannot be negative")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def development(cls) -> OwnershipTransferPolicy:
|
||||||
|
return cls(
|
||||||
|
recovery_assurance_profile="development-single-admin",
|
||||||
|
recovery_required_approvals=1,
|
||||||
|
recovery_delay_hours=0,
|
||||||
|
recent_authentication_required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ResourceOwnershipProvider(Protocol):
|
||||||
|
def current_owner(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
) -> OwnershipSubjectRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def authorize_ownership_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
action: str,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
current_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
) -> OwnershipActionDecision:
|
||||||
|
...
|
||||||
|
|
||||||
|
def apply_owner(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
expected_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
reason: str | None,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipProviderRegistration:
|
||||||
|
resource_type: str
|
||||||
|
provider: ResourceOwnershipProvider
|
||||||
|
module_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransfer(Base, TimestampMixin):
|
||||||
|
__tablename__ = "core_ownership_transfers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"resource_module",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_core_ownership_transfer_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_ownership_transfer_resource",
|
||||||
|
"tenant_id",
|
||||||
|
"resource_module",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"status",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_ownership_transfer_expiry",
|
||||||
|
"status",
|
||||||
|
"expires_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=lambda: str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
resource_module: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
resource_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
current_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
current_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
target_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
target_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
initiated_by_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
initiated_by_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
owner_approved_by_type: Mapped[str | None] = mapped_column(String(40))
|
||||||
|
owner_approved_by_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
target_accepted_by_type: Mapped[str | None] = mapped_column(String(40))
|
||||||
|
target_accepted_by_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text)
|
||||||
|
assurance_profile: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
required_approvals: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
approvals: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
execute_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_owner_initiated_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
idempotency_key: str,
|
||||||
|
reason: str | None = None,
|
||||||
|
expiry_days: int | None = None,
|
||||||
|
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
return _start_transfer(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
provider=provider,
|
||||||
|
actor=actor,
|
||||||
|
target_owner=target_owner,
|
||||||
|
kind=OwnershipTransferKind.OWNER_INITIATED,
|
||||||
|
initial_status=OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE,
|
||||||
|
authorization_action="propose_transfer",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
reason=reason,
|
||||||
|
expiry_days=expiry_days,
|
||||||
|
policy=policy,
|
||||||
|
now=now,
|
||||||
|
assurance_profile=None,
|
||||||
|
required_approvals=1,
|
||||||
|
execute_after=None,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request_ownership(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef | None = None,
|
||||||
|
idempotency_key: str,
|
||||||
|
reason: str | None = None,
|
||||||
|
expiry_days: int | None = None,
|
||||||
|
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
requested_owner = target_owner or actor
|
||||||
|
return _start_transfer(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
provider=provider,
|
||||||
|
actor=actor,
|
||||||
|
target_owner=requested_owner,
|
||||||
|
kind=OwnershipTransferKind.TARGET_REQUESTED,
|
||||||
|
initial_status=OwnershipTransferStatus.AWAITING_OWNER_APPROVAL,
|
||||||
|
authorization_action="request_ownership",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
reason=reason,
|
||||||
|
expiry_days=expiry_days,
|
||||||
|
policy=policy,
|
||||||
|
now=now,
|
||||||
|
assurance_profile=None,
|
||||||
|
required_approvals=1,
|
||||||
|
execute_after=None,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_ownership_request(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
if (
|
||||||
|
transfer.kind != OwnershipTransferKind.TARGET_REQUESTED.value
|
||||||
|
or transfer.status != OwnershipTransferStatus.AWAITING_OWNER_APPROVAL.value
|
||||||
|
):
|
||||||
|
raise OwnershipTransferError("Ownership request is not awaiting owner approval")
|
||||||
|
_authorize(provider, session, transfer, actor, "approve_requested_transfer")
|
||||||
|
transfer.owner_approved_by_type = actor.type
|
||||||
|
transfer.owner_approved_by_id = actor.id
|
||||||
|
transfer.status = OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="owner_approved",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "owner_approved", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def accept_ownership_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
if transfer.status != OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value:
|
||||||
|
raise OwnershipTransferError("Ownership transfer is not awaiting target acceptance")
|
||||||
|
action = (
|
||||||
|
"accept_group_transfer"
|
||||||
|
if transfer.target_owner_type == "group"
|
||||||
|
else "accept_transfer"
|
||||||
|
)
|
||||||
|
_authorize(provider, session, transfer, actor, action)
|
||||||
|
current_owner = _owner_ref(transfer, target=False)
|
||||||
|
target_owner = _owner_ref(transfer, target=True)
|
||||||
|
provider.apply_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=transfer.tenant_id,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
expected_owner=current_owner,
|
||||||
|
target_owner=target_owner,
|
||||||
|
actor=actor,
|
||||||
|
reason=transfer.reason,
|
||||||
|
)
|
||||||
|
transfer.target_accepted_by_type = actor.type
|
||||||
|
transfer.target_accepted_by_id = actor.id
|
||||||
|
transfer.status = OwnershipTransferStatus.COMPLETED.value
|
||||||
|
transfer.completed_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="accepted",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "completed", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def decline_ownership_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
_authorize(provider, session, transfer, actor, "decline_transfer")
|
||||||
|
transfer.status = OwnershipTransferStatus.DECLINED.value
|
||||||
|
transfer.declined_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="declined",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "declined", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_ownership_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
_authorize(provider, session, transfer, actor, "cancel_transfer")
|
||||||
|
transfer.status = OwnershipTransferStatus.CANCELLED.value
|
||||||
|
transfer.cancelled_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="cancelled",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "cancelled", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def start_administrative_recovery(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
idempotency_key: str,
|
||||||
|
reason: str,
|
||||||
|
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
if not reason.strip():
|
||||||
|
raise OwnershipTransferError("Administrative recovery requires a reason")
|
||||||
|
if policy.recent_authentication_required and not actor.recently_authenticated:
|
||||||
|
raise OwnershipAuthorizationError(
|
||||||
|
"Administrative recovery requires recent authentication"
|
||||||
|
)
|
||||||
|
effective_now = _utc(now)
|
||||||
|
transfer = _start_transfer(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
provider=provider,
|
||||||
|
actor=actor,
|
||||||
|
target_owner=target_owner,
|
||||||
|
kind=OwnershipTransferKind.ADMINISTRATIVE_RECOVERY,
|
||||||
|
initial_status=OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS,
|
||||||
|
authorization_action="request_recovery",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
reason=reason,
|
||||||
|
expiry_days=policy.default_expiry_days,
|
||||||
|
policy=policy,
|
||||||
|
now=effective_now,
|
||||||
|
assurance_profile=policy.recovery_assurance_profile,
|
||||||
|
required_approvals=policy.recovery_required_approvals,
|
||||||
|
execute_after=effective_now
|
||||||
|
+ timedelta(hours=policy.recovery_delay_hours),
|
||||||
|
metadata={
|
||||||
|
"recent_authentication_required": (
|
||||||
|
policy.recent_authentication_required
|
||||||
|
),
|
||||||
|
"encryption_keys_included": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def approve_administrative_recovery(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
_require_recovery_authentication(transfer, actor)
|
||||||
|
if (
|
||||||
|
transfer.kind != OwnershipTransferKind.ADMINISTRATIVE_RECOVERY.value
|
||||||
|
or transfer.status
|
||||||
|
not in {
|
||||||
|
OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS.value,
|
||||||
|
OwnershipTransferStatus.RECOVERY_SCHEDULED.value,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
raise OwnershipTransferError("Ownership recovery is not awaiting approval")
|
||||||
|
_authorize(provider, session, transfer, actor, "approve_recovery")
|
||||||
|
approvals = list(transfer.approvals or [])
|
||||||
|
if any(
|
||||||
|
item.get("actor_type") == actor.type and item.get("actor_id") == actor.id
|
||||||
|
for item in approvals
|
||||||
|
):
|
||||||
|
return transfer
|
||||||
|
approvals.append(
|
||||||
|
{
|
||||||
|
"actor_type": actor.type,
|
||||||
|
"actor_id": actor.id,
|
||||||
|
"approved_at": effective_now.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transfer.approvals = approvals
|
||||||
|
if len(approvals) >= transfer.required_approvals:
|
||||||
|
transfer.status = OwnershipTransferStatus.RECOVERY_SCHEDULED.value
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="recovery_approved",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
details={"approval_count": len(approvals)},
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "recovery_approved", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def execute_administrative_recovery(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
_require_recovery_authentication(transfer, actor)
|
||||||
|
if transfer.status != OwnershipTransferStatus.RECOVERY_SCHEDULED.value:
|
||||||
|
raise OwnershipTransferError("Ownership recovery has not reached its approval quorum")
|
||||||
|
if transfer.execute_after and _utc(transfer.execute_after) > effective_now:
|
||||||
|
raise OwnershipTransferError("Ownership recovery assurance delay has not elapsed")
|
||||||
|
_authorize(provider, session, transfer, actor, "execute_recovery")
|
||||||
|
provider.apply_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=transfer.tenant_id,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
expected_owner=_owner_ref(transfer, target=False),
|
||||||
|
target_owner=_owner_ref(transfer, target=True),
|
||||||
|
actor=actor,
|
||||||
|
reason=transfer.reason,
|
||||||
|
)
|
||||||
|
transfer.status = OwnershipTransferStatus.COMPLETED.value
|
||||||
|
transfer.completed_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="recovery_executed",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "recovery_completed", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def expire_due_ownership_transfers(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 250,
|
||||||
|
) -> int:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
rows = (
|
||||||
|
session.query(OwnershipTransfer)
|
||||||
|
.filter(
|
||||||
|
OwnershipTransfer.status.notin_(
|
||||||
|
sorted(TERMINAL_OWNERSHIP_TRANSFER_STATUSES)
|
||||||
|
),
|
||||||
|
OwnershipTransfer.expires_at <= effective_now,
|
||||||
|
)
|
||||||
|
.order_by(OwnershipTransfer.expires_at.asc(), OwnershipTransfer.id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for transfer in rows:
|
||||||
|
transfer.status = OwnershipTransferStatus.EXPIRED.value
|
||||||
|
transfer.expired_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="expired",
|
||||||
|
actor=None,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "expired", None)
|
||||||
|
session.flush()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
kind: OwnershipTransferKind,
|
||||||
|
initial_status: OwnershipTransferStatus,
|
||||||
|
authorization_action: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
reason: str | None,
|
||||||
|
expiry_days: int | None,
|
||||||
|
policy: OwnershipTransferPolicy,
|
||||||
|
now: datetime | None,
|
||||||
|
assurance_profile: str | None,
|
||||||
|
required_approvals: int,
|
||||||
|
execute_after: datetime | None,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
if not resource.module_id or resource.module_id != resource.module_id.strip():
|
||||||
|
raise OwnershipTransferError("Ownership resource module id is invalid")
|
||||||
|
clean_key = idempotency_key.strip()
|
||||||
|
if not clean_key or len(clean_key) > 200:
|
||||||
|
raise OwnershipTransferError("A bounded ownership idempotency key is required")
|
||||||
|
effective_now = _utc(now)
|
||||||
|
days = policy.default_expiry_days if expiry_days is None else expiry_days
|
||||||
|
if not policy.min_expiry_days <= days <= policy.max_expiry_days:
|
||||||
|
raise OwnershipTransferError(
|
||||||
|
"Ownership transfer expiry is outside the effective policy"
|
||||||
|
)
|
||||||
|
current_owner = provider.current_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource_id=resource.resource_id,
|
||||||
|
)
|
||||||
|
if current_owner is None:
|
||||||
|
raise OwnershipTransferError("Owned resource was not found")
|
||||||
|
if current_owner.type == target_owner.type and current_owner.id == target_owner.id:
|
||||||
|
raise OwnershipTransferError("Target is already the resource owner")
|
||||||
|
_authorize_direct(
|
||||||
|
provider,
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource_id=resource.resource_id,
|
||||||
|
current_owner=current_owner,
|
||||||
|
target_owner=target_owner,
|
||||||
|
actor=actor,
|
||||||
|
action=authorization_action,
|
||||||
|
)
|
||||||
|
request_hash = _request_hash(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
kind=kind,
|
||||||
|
current_owner=current_owner,
|
||||||
|
target_owner=target_owner,
|
||||||
|
actor=actor,
|
||||||
|
reason=reason,
|
||||||
|
expiry_days=days,
|
||||||
|
assurance_profile=assurance_profile,
|
||||||
|
required_approvals=required_approvals,
|
||||||
|
execute_after=execute_after,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
existing = (
|
||||||
|
session.query(OwnershipTransfer)
|
||||||
|
.filter(
|
||||||
|
OwnershipTransfer.tenant_id == tenant_id,
|
||||||
|
OwnershipTransfer.resource_module == resource.module_id,
|
||||||
|
OwnershipTransfer.idempotency_key == clean_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.canonical_request_hash != request_hash:
|
||||||
|
raise OwnershipIdempotencyConflict(
|
||||||
|
"Ownership idempotency key is already bound to another request"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
transfer = OwnershipTransfer(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource_module=resource.module_id,
|
||||||
|
resource_type=resource.resource_type,
|
||||||
|
resource_id=resource.resource_id,
|
||||||
|
kind=kind.value,
|
||||||
|
status=initial_status.value,
|
||||||
|
current_owner_type=current_owner.type,
|
||||||
|
current_owner_id=current_owner.id,
|
||||||
|
target_owner_type=target_owner.type,
|
||||||
|
target_owner_id=target_owner.id,
|
||||||
|
initiated_by_type=actor.type,
|
||||||
|
initiated_by_id=actor.id,
|
||||||
|
reason=reason.strip() if reason else None,
|
||||||
|
assurance_profile=assurance_profile,
|
||||||
|
required_approvals=required_approvals,
|
||||||
|
approvals=[],
|
||||||
|
decisions=[],
|
||||||
|
idempotency_key=clean_key,
|
||||||
|
canonical_request_hash=request_hash,
|
||||||
|
expires_at=effective_now + timedelta(days=days),
|
||||||
|
execute_after=execute_after,
|
||||||
|
metadata_=dict(metadata),
|
||||||
|
)
|
||||||
|
session.add(transfer)
|
||||||
|
session.flush()
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="started",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
details={
|
||||||
|
"kind": kind.value,
|
||||||
|
"initial_status": initial_status.value,
|
||||||
|
"assurance_profile": assurance_profile,
|
||||||
|
"required_approvals": required_approvals,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "started", actor)
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
session: Session,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
_authorize_direct(
|
||||||
|
provider,
|
||||||
|
session,
|
||||||
|
tenant_id=transfer.tenant_id,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
current_owner=_owner_ref(transfer, target=False),
|
||||||
|
target_owner=_owner_ref(transfer, target=True),
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize_direct(
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
current_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
decision = provider.authorize_ownership_action(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource_id=resource_id,
|
||||||
|
action=action,
|
||||||
|
actor=actor,
|
||||||
|
current_owner=current_owner,
|
||||||
|
target_owner=target_owner,
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
raise OwnershipAuthorizationError(
|
||||||
|
decision.reason or f"Ownership action is not allowed: {action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_active(
|
||||||
|
session: Session,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
now: datetime,
|
||||||
|
) -> None:
|
||||||
|
if transfer.status in TERMINAL_OWNERSHIP_TRANSFER_STATUSES:
|
||||||
|
raise OwnershipTransferError("Ownership transfer is already final")
|
||||||
|
if _utc(transfer.expires_at) <= now:
|
||||||
|
transfer.status = OwnershipTransferStatus.EXPIRED.value
|
||||||
|
transfer.expired_at = now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="expired",
|
||||||
|
actor=None,
|
||||||
|
decided_at=now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "expired", None)
|
||||||
|
raise OwnershipTransferExpired("Ownership transfer has expired")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_recovery_authentication(
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
bool((transfer.metadata_ or {}).get("recent_authentication_required"))
|
||||||
|
and not actor.recently_authenticated
|
||||||
|
):
|
||||||
|
raise OwnershipAuthorizationError(
|
||||||
|
"Administrative recovery requires recent authentication"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_ref(
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
*,
|
||||||
|
target: bool,
|
||||||
|
) -> OwnershipSubjectRef:
|
||||||
|
return OwnershipSubjectRef(
|
||||||
|
type=transfer.target_owner_type if target else transfer.current_owner_type,
|
||||||
|
id=transfer.target_owner_id if target else transfer.current_owner_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(transfer: OwnershipTransfer) -> None:
|
||||||
|
transfer.revision = int(transfer.revision or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _record_decision(
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
actor: OwnershipSubjectRef | None,
|
||||||
|
decided_at: datetime,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
decisions = list(transfer.decisions or [])
|
||||||
|
decisions.append(
|
||||||
|
{
|
||||||
|
"sequence": len(decisions) + 1,
|
||||||
|
"action": action,
|
||||||
|
"actor_type": actor.type if actor else None,
|
||||||
|
"actor_id": actor.id if actor else None,
|
||||||
|
"decided_at": _utc(decided_at).isoformat(),
|
||||||
|
"status": transfer.status,
|
||||||
|
"details": dict(details or {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transfer.decisions = decisions
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value: datetime | None) -> datetime:
|
||||||
|
if value is None:
|
||||||
|
return utcnow()
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_hash(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
kind: OwnershipTransferKind,
|
||||||
|
current_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
reason: str | None,
|
||||||
|
expiry_days: int,
|
||||||
|
assurance_profile: str | None,
|
||||||
|
required_approvals: int,
|
||||||
|
execute_after: datetime | None,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"resource": {
|
||||||
|
"module_id": resource.module_id,
|
||||||
|
"type": resource.resource_type,
|
||||||
|
"id": resource.resource_id,
|
||||||
|
},
|
||||||
|
"kind": kind.value,
|
||||||
|
"current_owner": {"type": current_owner.type, "id": current_owner.id},
|
||||||
|
"target_owner": {"type": target_owner.type, "id": target_owner.id},
|
||||||
|
"actor": {"type": actor.type, "id": actor.id},
|
||||||
|
"reason": reason.strip() if reason else None,
|
||||||
|
"expiry_days": expiry_days,
|
||||||
|
"assurance_profile": assurance_profile,
|
||||||
|
"required_approvals": required_approvals,
|
||||||
|
"execute_after": (
|
||||||
|
_utc(execute_after).isoformat()
|
||||||
|
if execute_after is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_transfer_event(
|
||||||
|
session: Session,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
action: str,
|
||||||
|
actor: OwnershipSubjectRef | None,
|
||||||
|
) -> None:
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=f"core.ownership_transfer.{action}.v1",
|
||||||
|
module_id="core",
|
||||||
|
tenant=EventTenantRef(id=transfer.tenant_id),
|
||||||
|
actor=EventActorRef(type=actor.type, id=actor.id) if actor else None,
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="ownership_transfer",
|
||||||
|
id=transfer.id,
|
||||||
|
),
|
||||||
|
classification="confidential",
|
||||||
|
payload={
|
||||||
|
"schema_version": 1,
|
||||||
|
"resource_module": transfer.resource_module,
|
||||||
|
"resource_type": transfer.resource_type,
|
||||||
|
"resource_id": transfer.resource_id,
|
||||||
|
"kind": transfer.kind,
|
||||||
|
"status": transfer.status,
|
||||||
|
"current_owner_type": transfer.current_owner_type,
|
||||||
|
"current_owner_id": transfer.current_owner_id,
|
||||||
|
"target_owner_type": transfer.target_owner_type,
|
||||||
|
"target_owner_id": transfer.target_owner_id,
|
||||||
|
"assurance_profile": transfer.assurance_profile,
|
||||||
|
"required_approvals": transfer.required_approvals,
|
||||||
|
"approval_count": len(transfer.approvals or []),
|
||||||
|
"encryption_keys_included": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OwnershipActionDecision",
|
||||||
|
"OwnershipAuthorizationError",
|
||||||
|
"OwnershipIdempotencyConflict",
|
||||||
|
"OwnershipProviderRegistration",
|
||||||
|
"OwnershipResourceRef",
|
||||||
|
"OwnershipSubjectRef",
|
||||||
|
"OwnershipTransfer",
|
||||||
|
"OwnershipTransferError",
|
||||||
|
"OwnershipTransferExpired",
|
||||||
|
"OwnershipTransferKind",
|
||||||
|
"OwnershipTransferPolicy",
|
||||||
|
"OwnershipTransferStatus",
|
||||||
|
"ResourceOwnershipProvider",
|
||||||
|
"accept_ownership_transfer",
|
||||||
|
"approve_administrative_recovery",
|
||||||
|
"approve_ownership_request",
|
||||||
|
"cancel_ownership_transfer",
|
||||||
|
"decline_ownership_transfer",
|
||||||
|
"execute_administrative_recovery",
|
||||||
|
"expire_due_ownership_transfers",
|
||||||
|
"request_ownership",
|
||||||
|
"start_administrative_recovery",
|
||||||
|
"start_owner_initiated_transfer",
|
||||||
|
]
|
||||||
@@ -25,6 +25,10 @@ from govoplan_core.core.modules import (
|
|||||||
TenantSummaryProvider,
|
TenantSummaryProvider,
|
||||||
user_workflow_scope_condition_issues,
|
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.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||||
from govoplan_core.core.search import (
|
from govoplan_core.core.search import (
|
||||||
RegisteredSearchProvider,
|
RegisteredSearchProvider,
|
||||||
@@ -65,6 +69,7 @@ class PlatformRegistry:
|
|||||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||||
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
||||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
||||||
|
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
|
||||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||||
self._capabilities: dict[str, object] = {}
|
self._capabilities: dict[str, object] = {}
|
||||||
self._capability_context: ModuleContext | None = None
|
self._capability_context: ModuleContext | None = None
|
||||||
@@ -86,6 +91,8 @@ class PlatformRegistry:
|
|||||||
for resource_type, providers in manifest.delete_veto_providers.items():
|
for resource_type, providers in manifest.delete_veto_providers.items():
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
self.register_delete_veto(manifest.id, resource_type, provider)
|
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():
|
for name, factory in manifest.capability_factories.items():
|
||||||
self.register_capability_factory(manifest.id, name, factory)
|
self.register_capability_factory(manifest.id, name, factory)
|
||||||
for registration in manifest.search_providers:
|
for registration in manifest.search_providers:
|
||||||
@@ -121,6 +128,7 @@ class PlatformRegistry:
|
|||||||
resource_type: list(providers)
|
resource_type: list(providers)
|
||||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
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._capability_factories = dict(replacement._capability_factories)
|
||||||
self._search_provider_registrations = list(
|
self._search_provider_registrations = list(
|
||||||
replacement._search_provider_registrations
|
replacement._search_provider_registrations
|
||||||
@@ -171,6 +179,44 @@ class PlatformRegistry:
|
|||||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
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:
|
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||||
self._capability_context = context
|
self._capability_context = context
|
||||||
self._capabilities.clear()
|
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.fastapi import create_govoplan_app
|
||||||
from govoplan_core.server.platform import create_platform_router
|
from govoplan_core.server.platform import create_platform_router
|
||||||
from govoplan_core.server.credentials import router as credential_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.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
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(router)
|
||||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||||
api_router.include_router(credential_router)
|
api_router.include_router(credential_router)
|
||||||
|
api_router.include_router(ownership_router)
|
||||||
for router in server_config.post_module_routers:
|
for router in server_config.post_module_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
for contribution in server_config.extra_routers:
|
for contribution in server_config.extra_routers:
|
||||||
|
|||||||
621
src/govoplan_core/server/ownership.py
Normal file
621
src/govoplan_core/server/ownership.py
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.core.ownership import (
|
||||||
|
OwnershipAuthorizationError,
|
||||||
|
OwnershipIdempotencyConflict,
|
||||||
|
OwnershipResourceRef,
|
||||||
|
OwnershipSubjectRef,
|
||||||
|
OwnershipTransfer,
|
||||||
|
OwnershipTransferError,
|
||||||
|
OwnershipTransferExpired,
|
||||||
|
OwnershipTransferPolicy,
|
||||||
|
accept_ownership_transfer,
|
||||||
|
approve_administrative_recovery,
|
||||||
|
approve_ownership_request,
|
||||||
|
cancel_ownership_transfer,
|
||||||
|
decline_ownership_transfer,
|
||||||
|
execute_administrative_recovery,
|
||||||
|
request_ownership,
|
||||||
|
start_administrative_recovery,
|
||||||
|
start_owner_initiated_transfer,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
|
||||||
|
|
||||||
|
RECENT_AUTHENTICATION_WINDOW = timedelta(minutes=15)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipSubjectRequest(BaseModel):
|
||||||
|
type: str = Field(min_length=1, max_length=40, pattern=r"^[a-z][a-z0-9_-]*$")
|
||||||
|
id: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipResourceRequest(BaseModel):
|
||||||
|
module_id: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
pattern=r"^[a-z][a-z0-9_-]*$",
|
||||||
|
)
|
||||||
|
resource_type: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
pattern=r"^[a-z][a-z0-9_-]*$",
|
||||||
|
)
|
||||||
|
resource_id: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferStartRequest(BaseModel):
|
||||||
|
resource: OwnershipResourceRequest
|
||||||
|
target_owner: OwnershipSubjectRequest
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=200)
|
||||||
|
reason: str | None = Field(default=None, max_length=4000)
|
||||||
|
expiry_days: int | None = Field(default=None, ge=1, le=30)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipRequestStartRequest(BaseModel):
|
||||||
|
resource: OwnershipResourceRequest
|
||||||
|
target_owner: OwnershipSubjectRequest | None = None
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=200)
|
||||||
|
reason: str | None = Field(default=None, max_length=4000)
|
||||||
|
expiry_days: int | None = Field(default=None, ge=1, le=30)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipRecoveryStartRequest(BaseModel):
|
||||||
|
resource: OwnershipResourceRequest
|
||||||
|
target_owner: OwnershipSubjectRequest
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=200)
|
||||||
|
reason: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
resource_module: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
kind: str
|
||||||
|
status: str
|
||||||
|
current_owner: OwnershipSubjectRequest
|
||||||
|
target_owner: OwnershipSubjectRequest
|
||||||
|
initiated_by: OwnershipSubjectRequest
|
||||||
|
owner_approved_by: OwnershipSubjectRequest | None = None
|
||||||
|
target_accepted_by: OwnershipSubjectRequest | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
assurance_profile: str | None = None
|
||||||
|
required_approvals: int
|
||||||
|
approvals: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
decisions: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
expires_at: datetime
|
||||||
|
execute_after: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
declined_at: datetime | None = None
|
||||||
|
cancelled_at: datetime | None = None
|
||||||
|
expired_at: datetime | None = None
|
||||||
|
revision: int
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferListResponse(BaseModel):
|
||||||
|
transfers: list[OwnershipTransferResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ownership/transfers", tags=["ownership"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=OwnershipTransferListResponse)
|
||||||
|
def list_ownership_transfers(
|
||||||
|
request: Request,
|
||||||
|
resource_type: str | None = Query(default=None, max_length=100),
|
||||||
|
resource_id: str | None = Query(default=None, max_length=255),
|
||||||
|
transfer_status: str | None = Query(default=None, alias="status", max_length=50),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferListResponse:
|
||||||
|
actor = _actor(principal)
|
||||||
|
query = session.query(OwnershipTransfer).filter(
|
||||||
|
OwnershipTransfer.tenant_id == principal.tenant_id
|
||||||
|
)
|
||||||
|
if resource_type:
|
||||||
|
query = query.filter(
|
||||||
|
OwnershipTransfer.resource_type == resource_type.strip().lower()
|
||||||
|
)
|
||||||
|
if resource_id:
|
||||||
|
query = query.filter(OwnershipTransfer.resource_id == resource_id)
|
||||||
|
if transfer_status:
|
||||||
|
query = query.filter(OwnershipTransfer.status == transfer_status)
|
||||||
|
rows = (
|
||||||
|
query.order_by(
|
||||||
|
OwnershipTransfer.created_at.desc(),
|
||||||
|
OwnershipTransfer.id.desc(),
|
||||||
|
)
|
||||||
|
.limit(min(limit * 5, 1000))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
visible = [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if _can_view_transfer(request, session, row, actor)
|
||||||
|
]
|
||||||
|
return OwnershipTransferListResponse(
|
||||||
|
transfers=[_response(row) for row in visible[:limit]]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{transfer_id}", response_model=OwnershipTransferResponse)
|
||||||
|
def get_ownership_transfer(
|
||||||
|
transfer_id: str,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
transfer = _visible_transfer(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
transfer_id,
|
||||||
|
)
|
||||||
|
return _response(transfer)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=OwnershipTransferResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_owner_initiated_transfer(
|
||||||
|
payload: OwnershipTransferStartRequest,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
return _write(
|
||||||
|
session,
|
||||||
|
lambda: start_owner_initiated_transfer(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
resource=_resource(payload.resource),
|
||||||
|
provider=_provider(request, payload.resource),
|
||||||
|
actor=_actor(principal),
|
||||||
|
target_owner=_subject(payload.target_owner),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
reason=payload.reason,
|
||||||
|
expiry_days=payload.expiry_days,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/requests",
|
||||||
|
response_model=OwnershipTransferResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_ownership_request(
|
||||||
|
payload: OwnershipRequestStartRequest,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
return _write(
|
||||||
|
session,
|
||||||
|
lambda: request_ownership(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
resource=_resource(payload.resource),
|
||||||
|
provider=_provider(request, payload.resource),
|
||||||
|
actor=_actor(principal),
|
||||||
|
target_owner=(
|
||||||
|
_subject(payload.target_owner) if payload.target_owner else None
|
||||||
|
),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
reason=payload.reason,
|
||||||
|
expiry_days=payload.expiry_days,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/recoveries",
|
||||||
|
response_model=OwnershipTransferResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_administrative_recovery(
|
||||||
|
payload: OwnershipRecoveryStartRequest,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
_require_recovery_authority(principal)
|
||||||
|
return _write(
|
||||||
|
session,
|
||||||
|
lambda: start_administrative_recovery(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
resource=_resource(payload.resource),
|
||||||
|
provider=_provider(request, payload.resource),
|
||||||
|
actor=_actor(principal),
|
||||||
|
target_owner=_subject(payload.target_owner),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
reason=payload.reason,
|
||||||
|
policy=OwnershipTransferPolicy(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{transfer_id}/owner-approval", response_model=OwnershipTransferResponse)
|
||||||
|
def approve_requested_transfer(
|
||||||
|
transfer_id: str,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
return _mutate_existing(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
transfer_id,
|
||||||
|
approve_ownership_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{transfer_id}/acceptance", response_model=OwnershipTransferResponse)
|
||||||
|
def accept_transfer(
|
||||||
|
transfer_id: str,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
return _mutate_existing(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
transfer_id,
|
||||||
|
accept_ownership_transfer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{transfer_id}/decline", response_model=OwnershipTransferResponse)
|
||||||
|
def decline_transfer(
|
||||||
|
transfer_id: str,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
return _mutate_existing(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
transfer_id,
|
||||||
|
decline_ownership_transfer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{transfer_id}/cancel", response_model=OwnershipTransferResponse)
|
||||||
|
def cancel_transfer(
|
||||||
|
transfer_id: str,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
return _mutate_existing(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
transfer_id,
|
||||||
|
cancel_ownership_transfer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{transfer_id}/recovery-approval",
|
||||||
|
response_model=OwnershipTransferResponse,
|
||||||
|
)
|
||||||
|
def approve_recovery(
|
||||||
|
transfer_id: str,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
_require_recovery_authority(principal)
|
||||||
|
return _mutate_existing(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
transfer_id,
|
||||||
|
approve_administrative_recovery,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{transfer_id}/recovery-execution",
|
||||||
|
response_model=OwnershipTransferResponse,
|
||||||
|
)
|
||||||
|
def execute_recovery(
|
||||||
|
transfer_id: str,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
_require_recovery_authority(principal)
|
||||||
|
return _mutate_existing(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
transfer_id,
|
||||||
|
execute_administrative_recovery,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mutate_existing(
|
||||||
|
request: Request,
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
transfer_id: str,
|
||||||
|
operation,
|
||||||
|
) -> OwnershipTransferResponse:
|
||||||
|
transfer = _tenant_transfer(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
transfer_id,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
provider = _provider_for_transfer(request, transfer)
|
||||||
|
return _write(
|
||||||
|
session,
|
||||||
|
lambda: operation(
|
||||||
|
session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=provider,
|
||||||
|
actor=_actor(principal),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(session: Session, operation) -> OwnershipTransferResponse:
|
||||||
|
try:
|
||||||
|
transfer = operation()
|
||||||
|
session.commit()
|
||||||
|
session.refresh(transfer)
|
||||||
|
return _response(transfer)
|
||||||
|
except OwnershipAuthorizationError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except OwnershipIdempotencyConflict as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except OwnershipTransferExpired as exc:
|
||||||
|
session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except OwnershipTransferError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_transfer(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
transfer_id: str,
|
||||||
|
*,
|
||||||
|
for_update: bool = False,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
query = session.query(OwnershipTransfer).filter(
|
||||||
|
OwnershipTransfer.id == transfer_id,
|
||||||
|
OwnershipTransfer.tenant_id == principal.tenant_id,
|
||||||
|
)
|
||||||
|
if for_update:
|
||||||
|
query = query.with_for_update()
|
||||||
|
transfer = query.one_or_none()
|
||||||
|
if transfer is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Ownership transfer not found",
|
||||||
|
)
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_transfer(
|
||||||
|
request: Request,
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
transfer_id: str,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
transfer = _tenant_transfer(session, principal, transfer_id)
|
||||||
|
if not _can_view_transfer(request, session, transfer, _actor(principal)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Ownership transfer not found",
|
||||||
|
)
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def _can_view_transfer(
|
||||||
|
request: Request,
|
||||||
|
session: Session,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
) -> bool:
|
||||||
|
provider = _provider_for_transfer(request, transfer)
|
||||||
|
decision = provider.authorize_ownership_action(
|
||||||
|
session,
|
||||||
|
tenant_id=transfer.tenant_id,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
action="view_transfer",
|
||||||
|
actor=actor,
|
||||||
|
current_owner=OwnershipSubjectRef(
|
||||||
|
type=transfer.current_owner_type,
|
||||||
|
id=transfer.current_owner_id,
|
||||||
|
),
|
||||||
|
target_owner=OwnershipSubjectRef(
|
||||||
|
type=transfer.target_owner_type,
|
||||||
|
id=transfer.target_owner_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return decision.allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _provider(request: Request, resource: OwnershipResourceRequest):
|
||||||
|
registry = _registry(request)
|
||||||
|
registration = registry.ownership_provider_registration(resource.resource_type)
|
||||||
|
if (
|
||||||
|
registration is None
|
||||||
|
or registration.module_id != resource.module_id
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Owned resource type is not available",
|
||||||
|
)
|
||||||
|
return registration.provider
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_for_transfer(request: Request, transfer: OwnershipTransfer):
|
||||||
|
return _provider(
|
||||||
|
request,
|
||||||
|
OwnershipResourceRequest(
|
||||||
|
module_id=transfer.resource_module,
|
||||||
|
resource_type=transfer.resource_type,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry(request: Request) -> PlatformRegistry:
|
||||||
|
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||||
|
if not isinstance(registry, PlatformRegistry):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Platform registry is not available",
|
||||||
|
)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(principal: ApiPrincipal) -> OwnershipSubjectRef:
|
||||||
|
actor_id = principal.membership_id
|
||||||
|
if not actor_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Ownership actions require a tenant membership",
|
||||||
|
)
|
||||||
|
return OwnershipSubjectRef(
|
||||||
|
type="user",
|
||||||
|
id=actor_id,
|
||||||
|
label=principal.display_name,
|
||||||
|
scopes=principal.scopes,
|
||||||
|
group_ids=principal.group_ids,
|
||||||
|
recently_authenticated=_recently_authenticated(principal),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recently_authenticated(principal: ApiPrincipal) -> bool:
|
||||||
|
auth_session = principal.auth_session
|
||||||
|
created_at = getattr(auth_session, "created_at", None)
|
||||||
|
if not isinstance(created_at, datetime):
|
||||||
|
return False
|
||||||
|
if created_at.tzinfo is None:
|
||||||
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||||
|
elapsed = datetime.now(timezone.utc) - created_at
|
||||||
|
return timedelta(0) <= elapsed <= RECENT_AUTHENTICATION_WINDOW
|
||||||
|
|
||||||
|
|
||||||
|
def _require_recovery_authority(principal: ApiPrincipal) -> None:
|
||||||
|
if not (
|
||||||
|
principal.has("admin:settings:write")
|
||||||
|
or principal.has("system:settings:write")
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Administrative ownership recovery authority is required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resource(payload: OwnershipResourceRequest) -> OwnershipResourceRef:
|
||||||
|
return OwnershipResourceRef(
|
||||||
|
module_id=payload.module_id,
|
||||||
|
resource_type=payload.resource_type,
|
||||||
|
resource_id=payload.resource_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject(payload: OwnershipSubjectRequest) -> OwnershipSubjectRef:
|
||||||
|
return OwnershipSubjectRef(type=payload.type, id=payload.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_subject(
|
||||||
|
subject_type: str | None,
|
||||||
|
subject_id: str | None,
|
||||||
|
) -> OwnershipSubjectRequest | None:
|
||||||
|
if not subject_type or not subject_id:
|
||||||
|
return None
|
||||||
|
return OwnershipSubjectRequest(type=subject_type, id=subject_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _response(transfer: OwnershipTransfer) -> OwnershipTransferResponse:
|
||||||
|
return OwnershipTransferResponse(
|
||||||
|
id=transfer.id,
|
||||||
|
tenant_id=transfer.tenant_id,
|
||||||
|
resource_module=transfer.resource_module,
|
||||||
|
resource_type=transfer.resource_type,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
kind=transfer.kind,
|
||||||
|
status=transfer.status,
|
||||||
|
current_owner=OwnershipSubjectRequest(
|
||||||
|
type=transfer.current_owner_type,
|
||||||
|
id=transfer.current_owner_id,
|
||||||
|
),
|
||||||
|
target_owner=OwnershipSubjectRequest(
|
||||||
|
type=transfer.target_owner_type,
|
||||||
|
id=transfer.target_owner_id,
|
||||||
|
),
|
||||||
|
initiated_by=OwnershipSubjectRequest(
|
||||||
|
type=transfer.initiated_by_type,
|
||||||
|
id=transfer.initiated_by_id,
|
||||||
|
),
|
||||||
|
owner_approved_by=_optional_subject(
|
||||||
|
transfer.owner_approved_by_type,
|
||||||
|
transfer.owner_approved_by_id,
|
||||||
|
),
|
||||||
|
target_accepted_by=_optional_subject(
|
||||||
|
transfer.target_accepted_by_type,
|
||||||
|
transfer.target_accepted_by_id,
|
||||||
|
),
|
||||||
|
reason=transfer.reason,
|
||||||
|
assurance_profile=transfer.assurance_profile,
|
||||||
|
required_approvals=transfer.required_approvals,
|
||||||
|
approvals=list(transfer.approvals or []),
|
||||||
|
decisions=list(transfer.decisions or []),
|
||||||
|
expires_at=transfer.expires_at,
|
||||||
|
execute_after=transfer.execute_after,
|
||||||
|
completed_at=transfer.completed_at,
|
||||||
|
declined_at=transfer.declined_at,
|
||||||
|
cancelled_at=transfer.cancelled_at,
|
||||||
|
expired_at=transfer.expired_at,
|
||||||
|
revision=transfer.revision,
|
||||||
|
metadata=dict(transfer.metadata_ or {}),
|
||||||
|
created_at=transfer.created_at,
|
||||||
|
updated_at=transfer.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
430
tests/test_ownership.py
Normal file
430
tests/test_ownership.py
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
|
from govoplan_core.core.ownership import (
|
||||||
|
OwnershipActionDecision,
|
||||||
|
OwnershipIdempotencyConflict,
|
||||||
|
OwnershipProviderRegistration,
|
||||||
|
OwnershipResourceRef,
|
||||||
|
OwnershipSubjectRef,
|
||||||
|
OwnershipTransfer,
|
||||||
|
OwnershipTransferError,
|
||||||
|
OwnershipTransferExpired,
|
||||||
|
OwnershipTransferPolicy,
|
||||||
|
OwnershipTransferStatus,
|
||||||
|
accept_ownership_transfer,
|
||||||
|
approve_administrative_recovery,
|
||||||
|
approve_ownership_request,
|
||||||
|
execute_administrative_recovery,
|
||||||
|
request_ownership,
|
||||||
|
start_administrative_recovery,
|
||||||
|
start_owner_initiated_transfer,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.runtime import (
|
||||||
|
clear_runtime,
|
||||||
|
configure_runtime,
|
||||||
|
get_runtime_context,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 7, 30, 10, 0, tzinfo=timezone.utc)
|
||||||
|
RESOURCE = OwnershipResourceRef(
|
||||||
|
module_id="campaigns",
|
||||||
|
resource_type="campaign",
|
||||||
|
resource_id="campaign-1",
|
||||||
|
)
|
||||||
|
OWNER = OwnershipSubjectRef(type="account", id="owner-1")
|
||||||
|
TARGET = OwnershipSubjectRef(type="account", id="target-1")
|
||||||
|
|
||||||
|
|
||||||
|
class _OwnershipProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.owner = OWNER
|
||||||
|
self.applied: list[tuple[OwnershipSubjectRef, OwnershipSubjectRef]] = []
|
||||||
|
|
||||||
|
def current_owner(self, session, *, tenant_id: str, resource_id: str):
|
||||||
|
if tenant_id != "tenant-1" or resource_id != RESOURCE.resource_id:
|
||||||
|
return None
|
||||||
|
return self.owner
|
||||||
|
|
||||||
|
def authorize_ownership_action(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
action: str,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
current_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
) -> OwnershipActionDecision:
|
||||||
|
allowed = False
|
||||||
|
if action in {"propose_transfer", "approve_requested_transfer"}:
|
||||||
|
allowed = actor.id == current_owner.id
|
||||||
|
elif action == "request_ownership":
|
||||||
|
allowed = actor.id == target_owner.id
|
||||||
|
elif action == "accept_transfer":
|
||||||
|
allowed = actor.id == target_owner.id
|
||||||
|
elif action == "accept_group_transfer":
|
||||||
|
allowed = actor.id == "group-manager-1"
|
||||||
|
elif action in {
|
||||||
|
"request_recovery",
|
||||||
|
"approve_recovery",
|
||||||
|
"execute_recovery",
|
||||||
|
}:
|
||||||
|
allowed = actor.id.startswith("recovery-officer-")
|
||||||
|
elif action in {"decline_transfer", "cancel_transfer"}:
|
||||||
|
allowed = actor.id in {current_owner.id, target_owner.id}
|
||||||
|
return OwnershipActionDecision(
|
||||||
|
allowed=allowed,
|
||||||
|
reason=None if allowed else f"Not authorized for {action}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_owner(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
expected_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
reason: str | None,
|
||||||
|
) -> None:
|
||||||
|
if self.owner != expected_owner:
|
||||||
|
raise OwnershipTransferError("Owner changed while transfer was pending")
|
||||||
|
self.applied.append((expected_owner, target_owner))
|
||||||
|
self.owner = target_owner
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.runtime_context = get_runtime_context()
|
||||||
|
clear_runtime()
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[OwnershipTransfer.__table__],
|
||||||
|
)
|
||||||
|
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
self.provider = _OwnershipProvider()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[OwnershipTransfer.__table__],
|
||||||
|
)
|
||||||
|
self.engine.dispose()
|
||||||
|
if self.runtime_context is not None:
|
||||||
|
configure_runtime(self.runtime_context)
|
||||||
|
|
||||||
|
def test_owner_initiated_transfer_requires_target_acceptance(self) -> None:
|
||||||
|
transfer = start_owner_initiated_transfer(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OWNER,
|
||||||
|
target_owner=TARGET,
|
||||||
|
idempotency_key="owner-transfer-1",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
transfer.status,
|
||||||
|
OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.provider.owner, OWNER)
|
||||||
|
|
||||||
|
accept_ownership_transfer(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=TARGET,
|
||||||
|
now=NOW + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(transfer.status, OwnershipTransferStatus.COMPLETED.value)
|
||||||
|
self.assertEqual(self.provider.owner, TARGET)
|
||||||
|
self.assertEqual(transfer.target_accepted_by_id, TARGET.id)
|
||||||
|
self.assertEqual(
|
||||||
|
["started", "accepted"],
|
||||||
|
[decision["action"] for decision in transfer.decisions],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_requested_transfer_requires_owner_then_requester(self) -> None:
|
||||||
|
transfer = request_ownership(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=TARGET,
|
||||||
|
idempotency_key="request-transfer-1",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
transfer.status,
|
||||||
|
OwnershipTransferStatus.AWAITING_OWNER_APPROVAL.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
approve_ownership_request(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OWNER,
|
||||||
|
now=NOW + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
transfer.status,
|
||||||
|
OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.provider.owner, OWNER)
|
||||||
|
|
||||||
|
accept_ownership_transfer(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=TARGET,
|
||||||
|
now=NOW + timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
self.assertEqual(self.provider.owner, TARGET)
|
||||||
|
|
||||||
|
def test_idempotency_key_replays_only_the_same_request(self) -> None:
|
||||||
|
first = start_owner_initiated_transfer(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OWNER,
|
||||||
|
target_owner=TARGET,
|
||||||
|
idempotency_key="same-key",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
replay = start_owner_initiated_transfer(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OWNER,
|
||||||
|
target_owner=TARGET,
|
||||||
|
idempotency_key="same-key",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertEqual(replay.id, first.id)
|
||||||
|
|
||||||
|
with self.assertRaises(OwnershipIdempotencyConflict):
|
||||||
|
start_owner_initiated_transfer(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OWNER,
|
||||||
|
target_owner=OwnershipSubjectRef(type="account", id="other"),
|
||||||
|
idempotency_key="same-key",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_group_target_acceptance_uses_dedicated_authority(self) -> None:
|
||||||
|
group = OwnershipSubjectRef(type="group", id="group-1")
|
||||||
|
transfer = start_owner_initiated_transfer(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OWNER,
|
||||||
|
target_owner=group,
|
||||||
|
idempotency_key="group-transfer",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
accept_ownership_transfer(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OwnershipSubjectRef(type="account", id="group-manager-1"),
|
||||||
|
now=NOW + timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.provider.owner, group)
|
||||||
|
self.assertEqual(transfer.target_accepted_by_id, "group-manager-1")
|
||||||
|
|
||||||
|
def test_standard_recovery_requires_quorum_and_delay(self) -> None:
|
||||||
|
policy = OwnershipTransferPolicy()
|
||||||
|
target = OwnershipSubjectRef(type="account", id="recovered-owner")
|
||||||
|
transfer = start_administrative_recovery(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OwnershipSubjectRef(
|
||||||
|
type="account",
|
||||||
|
id="recovery-officer-1",
|
||||||
|
recently_authenticated=True,
|
||||||
|
),
|
||||||
|
target_owner=target,
|
||||||
|
idempotency_key="recovery-1",
|
||||||
|
reason="Owner is unavailable",
|
||||||
|
policy=policy,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertFalse(transfer.metadata_["encryption_keys_included"])
|
||||||
|
|
||||||
|
approve_administrative_recovery(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OwnershipSubjectRef(
|
||||||
|
type="account",
|
||||||
|
id="recovery-officer-1",
|
||||||
|
recently_authenticated=True,
|
||||||
|
),
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
transfer.status,
|
||||||
|
OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS.value,
|
||||||
|
)
|
||||||
|
approve_administrative_recovery(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OwnershipSubjectRef(
|
||||||
|
type="account",
|
||||||
|
id="recovery-officer-2",
|
||||||
|
recently_authenticated=True,
|
||||||
|
),
|
||||||
|
now=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
transfer.status,
|
||||||
|
OwnershipTransferStatus.RECOVERY_SCHEDULED.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
OwnershipTransferError,
|
||||||
|
"delay has not elapsed",
|
||||||
|
):
|
||||||
|
execute_administrative_recovery(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OwnershipSubjectRef(
|
||||||
|
type="account",
|
||||||
|
id="recovery-officer-3",
|
||||||
|
recently_authenticated=True,
|
||||||
|
),
|
||||||
|
now=NOW + timedelta(hours=23),
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_administrative_recovery(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OwnershipSubjectRef(
|
||||||
|
type="account",
|
||||||
|
id="recovery-officer-3",
|
||||||
|
recently_authenticated=True,
|
||||||
|
),
|
||||||
|
now=NOW + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
self.assertEqual(self.provider.owner, target)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"started",
|
||||||
|
"recovery_approved",
|
||||||
|
"recovery_approved",
|
||||||
|
"recovery_executed",
|
||||||
|
],
|
||||||
|
[decision["action"] for decision in transfer.decisions],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
2,
|
||||||
|
transfer.decisions[0]["details"]["required_approvals"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_standard_recovery_rejects_stale_authentication_context(self) -> None:
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
PermissionError,
|
||||||
|
"recent authentication",
|
||||||
|
):
|
||||||
|
start_administrative_recovery(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OwnershipSubjectRef(
|
||||||
|
type="account",
|
||||||
|
id="recovery-officer-1",
|
||||||
|
),
|
||||||
|
target_owner=OwnershipSubjectRef(
|
||||||
|
type="account",
|
||||||
|
id="recovered-owner",
|
||||||
|
),
|
||||||
|
idempotency_key="stale-recovery",
|
||||||
|
reason="Owner is unavailable",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expiry_is_recorded_before_a_late_decision_is_rejected(self) -> None:
|
||||||
|
transfer = start_owner_initiated_transfer(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource=RESOURCE,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=OWNER,
|
||||||
|
target_owner=TARGET,
|
||||||
|
idempotency_key="expiring-transfer",
|
||||||
|
expiry_days=1,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(OwnershipTransferExpired):
|
||||||
|
accept_ownership_transfer(
|
||||||
|
self.session,
|
||||||
|
transfer=transfer,
|
||||||
|
provider=self.provider,
|
||||||
|
actor=TARGET,
|
||||||
|
now=NOW + timedelta(days=2),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
OwnershipTransferStatus.EXPIRED.value,
|
||||||
|
transfer.status,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["started", "expired"],
|
||||||
|
[decision["action"] for decision in transfer.decisions],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registry_resolves_one_provider_per_resource_type(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="campaigns",
|
||||||
|
name="Campaigns",
|
||||||
|
version="1.0.0",
|
||||||
|
ownership_providers=(
|
||||||
|
OwnershipProviderRegistration(
|
||||||
|
resource_type="campaign",
|
||||||
|
provider=self.provider,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(registry.ownership_provider("CAMPAIGN"), self.provider)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
216
tests/test_ownership_api.py
Normal file
216
tests/test_ownership_api.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
|
from govoplan_core.core.ownership import (
|
||||||
|
OwnershipActionDecision,
|
||||||
|
OwnershipProviderRegistration,
|
||||||
|
OwnershipSubjectRef,
|
||||||
|
OwnershipTransfer,
|
||||||
|
OwnershipTransferError,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.runtime import (
|
||||||
|
clear_runtime,
|
||||||
|
configure_runtime,
|
||||||
|
get_runtime_context,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.server.ownership import router
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.owner = OwnershipSubjectRef(type="user", id="owner-1")
|
||||||
|
|
||||||
|
def current_owner(self, session, *, tenant_id: str, resource_id: str):
|
||||||
|
if tenant_id == "tenant-1" and resource_id == "resource-1":
|
||||||
|
return self.owner
|
||||||
|
return None
|
||||||
|
|
||||||
|
def authorize_ownership_action(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
action: str,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
current_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
) -> OwnershipActionDecision:
|
||||||
|
del session, tenant_id, resource_id
|
||||||
|
if action in {
|
||||||
|
"propose_transfer",
|
||||||
|
"approve_requested_transfer",
|
||||||
|
"cancel_transfer",
|
||||||
|
}:
|
||||||
|
allowed = actor.id == current_owner.id
|
||||||
|
elif action in {
|
||||||
|
"request_ownership",
|
||||||
|
"accept_transfer",
|
||||||
|
"decline_transfer",
|
||||||
|
}:
|
||||||
|
allowed = actor.id == target_owner.id
|
||||||
|
elif action == "view_transfer":
|
||||||
|
allowed = actor.id in {current_owner.id, target_owner.id}
|
||||||
|
else:
|
||||||
|
allowed = False
|
||||||
|
return OwnershipActionDecision(allowed=allowed)
|
||||||
|
|
||||||
|
def apply_owner(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
expected_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
reason: str | None,
|
||||||
|
) -> None:
|
||||||
|
del session, tenant_id, resource_id, actor, reason
|
||||||
|
if (
|
||||||
|
self.owner.type != expected_owner.type
|
||||||
|
or self.owner.id != expected_owner.id
|
||||||
|
):
|
||||||
|
raise OwnershipTransferError("Owner changed")
|
||||||
|
self.owner = target_owner
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipApiTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.runtime_context = get_runtime_context()
|
||||||
|
clear_runtime()
|
||||||
|
self.engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[OwnershipTransfer.__table__],
|
||||||
|
)
|
||||||
|
self.session = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)()
|
||||||
|
self.provider = _Provider()
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.0.0",
|
||||||
|
ownership_providers=(
|
||||||
|
OwnershipProviderRegistration(
|
||||||
|
resource_type="example",
|
||||||
|
provider=self.provider,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.principal = _principal("owner-1")
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
|
app.include_router(router, prefix="/api/v1")
|
||||||
|
app.dependency_overrides[get_session] = lambda: self.session
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: self.principal
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.client.close()
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[OwnershipTransfer.__table__],
|
||||||
|
)
|
||||||
|
self.engine.dispose()
|
||||||
|
if self.runtime_context is not None:
|
||||||
|
configure_runtime(self.runtime_context)
|
||||||
|
|
||||||
|
def test_owner_transfer_requires_target_acceptance_and_is_listable(self) -> None:
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/v1/ownership/transfers",
|
||||||
|
json={
|
||||||
|
"resource": {
|
||||||
|
"module_id": "example",
|
||||||
|
"resource_type": "example",
|
||||||
|
"resource_id": "resource-1",
|
||||||
|
},
|
||||||
|
"target_owner": {"type": "user", "id": "target-1"},
|
||||||
|
"idempotency_key": "api-transfer-1",
|
||||||
|
"reason": "Handover",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code, created.text)
|
||||||
|
transfer_id = created.json()["id"]
|
||||||
|
self.assertEqual(
|
||||||
|
"awaiting_target_acceptance",
|
||||||
|
created.json()["status"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["started"],
|
||||||
|
[item["action"] for item in created.json()["decisions"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
visible = self.client.get("/api/v1/ownership/transfers")
|
||||||
|
self.assertEqual(200, visible.status_code, visible.text)
|
||||||
|
self.assertEqual([transfer_id], [item["id"] for item in visible.json()["transfers"]])
|
||||||
|
|
||||||
|
self.principal = _principal("target-1")
|
||||||
|
accepted = self.client.post(
|
||||||
|
f"/api/v1/ownership/transfers/{transfer_id}/acceptance"
|
||||||
|
)
|
||||||
|
self.assertEqual(200, accepted.status_code, accepted.text)
|
||||||
|
self.assertEqual("completed", accepted.json()["status"])
|
||||||
|
self.assertEqual("target-1", self.provider.owner.id)
|
||||||
|
self.assertEqual(
|
||||||
|
["started", "accepted"],
|
||||||
|
[item["action"] for item in accepted.json()["decisions"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resource_module_must_match_registered_provider(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/ownership/transfers",
|
||||||
|
json={
|
||||||
|
"resource": {
|
||||||
|
"module_id": "wrong-module",
|
||||||
|
"resource_type": "example",
|
||||||
|
"resource_id": "resource-1",
|
||||||
|
},
|
||||||
|
"target_owner": {"type": "user", "id": "target-1"},
|
||||||
|
"idempotency_key": "wrong-provider-module",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(404, response.status_code, response.text)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(user_id: str) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id=f"account-{user_id}",
|
||||||
|
membership_id=user_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset({"campaigns:campaign:share"}),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id=f"account-{user_id}"),
|
||||||
|
user=SimpleNamespace(id=user_id),
|
||||||
|
auth_session=SimpleNamespace(created_at=datetime.now(timezone.utc)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -69,7 +69,7 @@ class WheelRuntimeTests(unittest.TestCase):
|
|||||||
self.assertNotEqual(repository_root, runtime_root)
|
self.assertNotEqual(repository_root, runtime_root)
|
||||||
self.assertTrue((runtime_root / "alembic.ini").is_file())
|
self.assertTrue((runtime_root / "alembic.ini").is_file())
|
||||||
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
|
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
|
||||||
self.assertEqual(["c91f0a72be34"], result["heads"])
|
self.assertEqual(["d03a7b9c1e5f"], result["heads"])
|
||||||
self.assertIn("core_scopes", result["tables"])
|
self.assertIn("core_scopes", result["tables"])
|
||||||
self.assertIn("core_system_settings", result["tables"])
|
self.assertIn("core_system_settings", result["tables"])
|
||||||
|
|
||||||
|
|||||||
142
webui/src/api/ownership.ts
Normal file
142
webui/src/api/ownership.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { apiFetch } from "./client";
|
||||||
|
import type { ApiSettings } from "../types";
|
||||||
|
|
||||||
|
export type OwnershipSubject = {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnershipResource = {
|
||||||
|
module_id: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnershipDecision = {
|
||||||
|
sequence: number;
|
||||||
|
action: string;
|
||||||
|
actor_type?: string | null;
|
||||||
|
actor_id?: string | null;
|
||||||
|
decided_at: string;
|
||||||
|
status: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnershipTransfer = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
resource_module: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
kind:
|
||||||
|
| "owner_initiated"
|
||||||
|
| "target_requested"
|
||||||
|
| "administrative_recovery";
|
||||||
|
status: string;
|
||||||
|
current_owner: OwnershipSubject;
|
||||||
|
target_owner: OwnershipSubject;
|
||||||
|
initiated_by: OwnershipSubject;
|
||||||
|
owner_approved_by?: OwnershipSubject | null;
|
||||||
|
target_accepted_by?: OwnershipSubject | null;
|
||||||
|
reason?: string | null;
|
||||||
|
assurance_profile?: string | null;
|
||||||
|
required_approvals: number;
|
||||||
|
approvals: Array<Record<string, unknown>>;
|
||||||
|
decisions: OwnershipDecision[];
|
||||||
|
expires_at: string;
|
||||||
|
execute_after?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
declined_at?: string | null;
|
||||||
|
cancelled_at?: string | null;
|
||||||
|
expired_at?: string | null;
|
||||||
|
revision: number;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnershipTransferStart = {
|
||||||
|
resource: OwnershipResource;
|
||||||
|
target_owner: OwnershipSubject;
|
||||||
|
idempotency_key: string;
|
||||||
|
reason?: string | null;
|
||||||
|
expiry_days?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnershipRequestStart = {
|
||||||
|
resource: OwnershipResource;
|
||||||
|
target_owner?: OwnershipSubject | null;
|
||||||
|
idempotency_key: string;
|
||||||
|
reason?: string | null;
|
||||||
|
expiry_days?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listOwnershipTransfers(
|
||||||
|
settings: ApiSettings,
|
||||||
|
filters: {
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
status?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<OwnershipTransfer[]> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (filters.resourceType) {
|
||||||
|
query.set("resource_type", filters.resourceType);
|
||||||
|
}
|
||||||
|
if (filters.resourceId) query.set("resource_id", filters.resourceId);
|
||||||
|
if (filters.status) query.set("status", filters.status);
|
||||||
|
if (filters.limit) query.set("limit", String(filters.limit));
|
||||||
|
const suffix = query.toString();
|
||||||
|
const result = await apiFetch<{ transfers: OwnershipTransfer[] }>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/ownership/transfers${suffix ? `?${suffix}` : ""}`
|
||||||
|
);
|
||||||
|
return result.transfers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOwnershipTransfer(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: OwnershipTransferStart
|
||||||
|
): Promise<OwnershipTransfer> {
|
||||||
|
return apiFetch<OwnershipTransfer>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/ownership/transfers",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestResourceOwnership(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: OwnershipRequestStart
|
||||||
|
): Promise<OwnershipTransfer> {
|
||||||
|
return apiFetch<OwnershipTransfer>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/ownership/transfers/requests",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideOwnershipTransfer(
|
||||||
|
settings: ApiSettings,
|
||||||
|
transferId: string,
|
||||||
|
action:
|
||||||
|
| "owner-approval"
|
||||||
|
| "acceptance"
|
||||||
|
| "decline"
|
||||||
|
| "cancel"
|
||||||
|
| "recovery-approval"
|
||||||
|
| "recovery-execution"
|
||||||
|
): Promise<OwnershipTransfer> {
|
||||||
|
return apiFetch<OwnershipTransfer>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/ownership/transfers/${transferId}/${action}`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -183,4 +183,24 @@ export { default as IconRail } from "./layout/IconRail";
|
|||||||
export { default as LanguageMenu } from "./layout/LanguageMenu";
|
export { default as LanguageMenu } from "./layout/LanguageMenu";
|
||||||
export { default as ModuleSubnav } from "./layout/ModuleSubnav";
|
export { default as ModuleSubnav } from "./layout/ModuleSubnav";
|
||||||
export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav";
|
export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav";
|
||||||
|
export { default as TreeSubnav } from "./layout/TreeSubnav";
|
||||||
|
export type {
|
||||||
|
TreeSubnavBranch,
|
||||||
|
TreeSubnavItem,
|
||||||
|
TreeSubnavNode
|
||||||
|
} from "./layout/TreeSubnav";
|
||||||
|
export {
|
||||||
|
decideOwnershipTransfer,
|
||||||
|
listOwnershipTransfers,
|
||||||
|
requestResourceOwnership,
|
||||||
|
startOwnershipTransfer
|
||||||
|
} from "./api/ownership";
|
||||||
|
export type {
|
||||||
|
OwnershipDecision,
|
||||||
|
OwnershipRequestStart,
|
||||||
|
OwnershipResource,
|
||||||
|
OwnershipSubject,
|
||||||
|
OwnershipTransfer,
|
||||||
|
OwnershipTransferStart
|
||||||
|
} from "./api/ownership";
|
||||||
export { default as Titlebar } from "./layout/Titlebar";
|
export { default as Titlebar } from "./layout/Titlebar";
|
||||||
|
|||||||
162
webui/src/layout/TreeSubnav.tsx
Normal file
162
webui/src/layout/TreeSubnav.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
|
export type TreeSubnavItem<T extends string> = {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
subtle?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TreeSubnavBranch<T extends string> = {
|
||||||
|
branchId: string;
|
||||||
|
label: string;
|
||||||
|
children: TreeSubnavNode<T>[];
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TreeSubnavNode<T extends string> =
|
||||||
|
| TreeSubnavItem<T>
|
||||||
|
| TreeSubnavBranch<T>;
|
||||||
|
|
||||||
|
function isBranch<T extends string>(
|
||||||
|
node: TreeSubnavNode<T>
|
||||||
|
): node is TreeSubnavBranch<T> {
|
||||||
|
return "branchId" in node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchIdsContaining<T extends string>(
|
||||||
|
nodes: TreeSubnavNode<T>[],
|
||||||
|
active: T
|
||||||
|
): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!isBranch(node)) continue;
|
||||||
|
const nested = branchIdsContaining(node.children, active);
|
||||||
|
if (
|
||||||
|
nested.length > 0 ||
|
||||||
|
node.children.some((child) => !isBranch(child) && child.id === active)
|
||||||
|
) {
|
||||||
|
result.push(node.branchId, ...nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultExpandedBranchIds<T extends string>(
|
||||||
|
nodes: TreeSubnavNode<T>[]
|
||||||
|
): string[] {
|
||||||
|
return nodes.flatMap((node) => {
|
||||||
|
if (!isBranch(node)) return [];
|
||||||
|
return [
|
||||||
|
...(node.defaultExpanded === false ? [] : [node.branchId]),
|
||||||
|
...defaultExpandedBranchIds(node.children)
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TreeSubnav<T extends string>({
|
||||||
|
active,
|
||||||
|
nodes,
|
||||||
|
onSelect,
|
||||||
|
ariaLabel,
|
||||||
|
className = ""
|
||||||
|
}: {
|
||||||
|
active: T;
|
||||||
|
nodes: TreeSubnavNode<T>[];
|
||||||
|
onSelect: (section: T) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const initialExpanded = useMemo(
|
||||||
|
() => defaultExpandedBranchIds(nodes),
|
||||||
|
[nodes]
|
||||||
|
);
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(
|
||||||
|
() => new Set(initialExpanded)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeBranchIds = branchIdsContaining(nodes, active);
|
||||||
|
setExpanded((current) => {
|
||||||
|
if (activeBranchIds.every((branchId) => current.has(branchId))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = new Set(current);
|
||||||
|
activeBranchIds.forEach((branchId) => next.add(branchId));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [active, nodes]);
|
||||||
|
|
||||||
|
function toggleBranch(branchId: string) {
|
||||||
|
setExpanded((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(branchId)) next.delete(branchId);
|
||||||
|
else next.add(branchId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodes(
|
||||||
|
currentNodes: TreeSubnavNode<T>[],
|
||||||
|
depth: number
|
||||||
|
) {
|
||||||
|
return currentNodes.map((node) => {
|
||||||
|
const depthStyle = { "--tree-depth": depth } as CSSProperties;
|
||||||
|
if (isBranch(node)) {
|
||||||
|
const isExpanded = expanded.has(node.branchId);
|
||||||
|
const containsActive = branchIdsContaining([node], active).length > 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`tree-subnav-branch${containsActive ? " contains-active" : ""}`}
|
||||||
|
key={node.branchId}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tree-subnav-disclosure"
|
||||||
|
style={depthStyle}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={() => toggleBranch(node.branchId)}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown aria-hidden="true" size={16} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight aria-hidden="true" size={16} />
|
||||||
|
)}
|
||||||
|
<span>{translateText(node.label)}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="tree-subnav-children">
|
||||||
|
{renderNodes(node.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={node.id}
|
||||||
|
className={`tree-subnav-link${node.subtle ? " subtle" : ""}${
|
||||||
|
active === node.id ? " active" : ""
|
||||||
|
}`}
|
||||||
|
style={depthStyle}
|
||||||
|
aria-current={active === node.id ? "page" : undefined}
|
||||||
|
onClick={() => onSelect(node.id)}
|
||||||
|
>
|
||||||
|
{translateText(node.label)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`section-sidebar tree-subnav ${className}`.trim()}
|
||||||
|
aria-label={translateText(ariaLabel)}
|
||||||
|
>
|
||||||
|
{renderNodes(nodes, 0)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,6 +73,48 @@
|
|||||||
.section-link:hover, .section-link.active { background: var(--sidebar-hover-bg); color: var(--text-hover-strong); }
|
.section-link:hover, .section-link.active { background: var(--sidebar-hover-bg); color: var(--text-hover-strong); }
|
||||||
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
|
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
|
||||||
.section-link.subtle { font-size: 13px; }
|
.section-link.subtle { font-size: 13px; }
|
||||||
|
.tree-subnav { padding: 10px 0; }
|
||||||
|
.tree-subnav-disclosure,
|
||||||
|
.tree-subnav-link {
|
||||||
|
width: calc(100% + 3px);
|
||||||
|
min-height: 38px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 0;
|
||||||
|
margin-left: -3px;
|
||||||
|
padding: 7px 10px 7px calc(12px + (var(--tree-depth, 0) * 14px));
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tree-subnav-disclosure {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.tree-subnav-disclosure svg { flex: 0 0 auto; }
|
||||||
|
.tree-subnav-link {
|
||||||
|
padding-left: calc(34px + (var(--tree-depth, 0) * 14px));
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tree-subnav-disclosure:hover,
|
||||||
|
.tree-subnav-link:hover,
|
||||||
|
.tree-subnav-link.active {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
color: var(--text-hover-strong);
|
||||||
|
}
|
||||||
|
.tree-subnav-link.active {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.tree-subnav-link.subtle { color: var(--muted); }
|
||||||
|
.tree-subnav-branch.contains-active > .tree-subnav-disclosure {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
|
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
|
||||||
.page-scroll-viewport { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: auto; }
|
.page-scroll-viewport { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: auto; }
|
||||||
.ui-no-sticky-section-sidebars .app-content { overflow: auto; }
|
.ui-no-sticky-section-sidebars .app-content { overflow: auto; }
|
||||||
|
|||||||
@@ -283,6 +283,12 @@ export type AdminSectionRenderContext = PlatformRouteContext & {
|
|||||||
export type AdminSectionContribution = {
|
export type AdminSectionContribution = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Owning module, used to group module-specific administration. */
|
||||||
|
moduleId?: string;
|
||||||
|
/** Distinguishes operational management pages from configurable settings. */
|
||||||
|
kind?: "management" | "settings";
|
||||||
|
/** Optional parent section for future embedded or nested settings surfaces. */
|
||||||
|
parentId?: string;
|
||||||
group?: AdminSectionGroup;
|
group?: AdminSectionGroup;
|
||||||
order?: number;
|
order?: number;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user