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
|
||||
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 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.core.migrations import migration_metadata_plan
|
||||
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 collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NotificationMailDeliveryRequest:
|
||||
tenant_id: str
|
||||
notification_id: str
|
||||
recipient: str
|
||||
subject: str
|
||||
body_text: str
|
||||
body_html: str | None = None
|
||||
action_url: str | None = None
|
||||
mail_profile_id: str | None = None
|
||||
from_address: str | None = None
|
||||
smtp_server_id: str | None = None
|
||||
smtp_credential_id: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class NotificationMailDeliveryProvider(Protocol):
|
||||
"""Mail-owned durable submission boundary for notification email."""
|
||||
|
||||
def submit_notification_mail(
|
||||
self,
|
||||
session: object,
|
||||
request: NotificationMailDeliveryRequest,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -28,3 +58,21 @@ class MailDeliveryOutboxProvider(Protocol):
|
||||
limit: int = 250,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def notification_mail_delivery_provider(
|
||||
registry: object | None,
|
||||
) -> NotificationMailDeliveryProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
|
||||
):
|
||||
return None
|
||||
provider = registry.require_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
|
||||
if not isinstance(provider, NotificationMailDeliveryProvider):
|
||||
raise TypeError(
|
||||
"mail.notificationDelivery provider does not implement "
|
||||
"NotificationMailDeliveryProvider"
|
||||
)
|
||||
return provider
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -405,6 +406,7 @@ class ModuleManifest:
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
frontend: FrontendModule | None = None
|
||||
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
||||
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
|
||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||
|
||||
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,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.ownership import (
|
||||
OwnershipProviderRegistration,
|
||||
ResourceOwnershipProvider,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.core.search import (
|
||||
RegisteredSearchProvider,
|
||||
@@ -65,6 +69,7 @@ class PlatformRegistry:
|
||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
||||
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
|
||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||
self._capabilities: dict[str, object] = {}
|
||||
self._capability_context: ModuleContext | None = None
|
||||
@@ -86,6 +91,8 @@ class PlatformRegistry:
|
||||
for resource_type, providers in manifest.delete_veto_providers.items():
|
||||
for provider in providers:
|
||||
self.register_delete_veto(manifest.id, resource_type, provider)
|
||||
for registration in manifest.ownership_providers:
|
||||
self.register_ownership_provider(manifest.id, registration)
|
||||
for name, factory in manifest.capability_factories.items():
|
||||
self.register_capability_factory(manifest.id, name, factory)
|
||||
for registration in manifest.search_providers:
|
||||
@@ -121,6 +128,7 @@ class PlatformRegistry:
|
||||
resource_type: list(providers)
|
||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||
})
|
||||
self._ownership_providers = dict(replacement._ownership_providers)
|
||||
self._capability_factories = dict(replacement._capability_factories)
|
||||
self._search_provider_registrations = list(
|
||||
replacement._search_provider_registrations
|
||||
@@ -171,6 +179,44 @@ class PlatformRegistry:
|
||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||
|
||||
def register_ownership_provider(
|
||||
self,
|
||||
module_id: str,
|
||||
registration: OwnershipProviderRegistration,
|
||||
) -> None:
|
||||
resource_type = registration.resource_type.strip().lower()
|
||||
if not resource_type:
|
||||
raise RegistryError(
|
||||
f"Ownership provider in {module_id} has no resource type"
|
||||
)
|
||||
if resource_type in self._ownership_providers:
|
||||
raise RegistryError(
|
||||
f"Duplicate ownership provider for resource type: {resource_type}"
|
||||
)
|
||||
if not isinstance(registration.provider, ResourceOwnershipProvider):
|
||||
raise RegistryError(
|
||||
f"Ownership provider for {resource_type} does not implement "
|
||||
"ResourceOwnershipProvider"
|
||||
)
|
||||
self._ownership_providers[resource_type] = OwnershipProviderRegistration(
|
||||
resource_type=resource_type,
|
||||
provider=registration.provider,
|
||||
module_id=module_id,
|
||||
)
|
||||
|
||||
def ownership_provider(
|
||||
self,
|
||||
resource_type: str,
|
||||
) -> ResourceOwnershipProvider | None:
|
||||
registration = self.ownership_provider_registration(resource_type)
|
||||
return registration.provider if registration else None
|
||||
|
||||
def ownership_provider_registration(
|
||||
self,
|
||||
resource_type: str,
|
||||
) -> OwnershipProviderRegistration | None:
|
||||
return self._ownership_providers.get(resource_type.strip().lower())
|
||||
|
||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||
self._capability_context = context
|
||||
self._capabilities.clear()
|
||||
|
||||
@@ -9,6 +9,7 @@ from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.credentials import router as credential_router
|
||||
from govoplan_core.server.ownership import router as ownership_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
||||
|
||||
@@ -69,6 +70,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
|
||||
api_router.include_router(router)
|
||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||
api_router.include_router(credential_router)
|
||||
api_router.include_router(ownership_router)
|
||||
for router in server_config.post_module_routers:
|
||||
api_router.include_router(router)
|
||||
for contribution in server_config.extra_routers:
|
||||
|
||||
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.assertTrue((runtime_root / "alembic.ini").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_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 ModuleSubnav } 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";
|
||||
|
||||
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.active { border-left: 3px solid var(--accent); font-weight: 700; }
|
||||
.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; }
|
||||
.page-scroll-viewport { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: auto; }
|
||||
.ui-no-sticky-section-sidebars .app-content { overflow: auto; }
|
||||
|
||||
@@ -283,6 +283,12 @@ export type AdminSectionRenderContext = PlatformRouteContext & {
|
||||
export type AdminSectionContribution = {
|
||||
id: 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;
|
||||
order?: number;
|
||||
anyOf?: string[];
|
||||
|
||||
Reference in New Issue
Block a user