diff --git a/alembic/dev_versions/d03a7b9c1e5f_core_ownership_transfers.py b/alembic/dev_versions/d03a7b9c1e5f_core_ownership_transfers.py new file mode 100644 index 0000000..cb8a72b --- /dev/null +++ b/alembic/dev_versions/d03a7b9c1e5f_core_ownership_transfers.py @@ -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 diff --git a/alembic/env.py b/alembic/env.py index 65cd967..533f0f6 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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 diff --git a/alembic/versions/d03a7b9c1e5f_core_ownership_transfers.py b/alembic/versions/d03a7b9c1e5f_core_ownership_transfers.py new file mode 100644 index 0000000..5923172 --- /dev/null +++ b/alembic/versions/d03a7b9c1e5f_core_ownership_transfers.py @@ -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") diff --git a/src/govoplan_core/core/mail.py b/src/govoplan_core/core/mail.py index a3d7a2f..989218d 100644 --- a/src/govoplan_core/core/mail.py +++ b/src/govoplan_core/core/mail.py @@ -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 diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 6408d9b..ff80e2a 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -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) diff --git a/src/govoplan_core/core/ownership.py b/src/govoplan_core/core/ownership.py new file mode 100644 index 0000000..87cb17e --- /dev/null +++ b/src/govoplan_core/core/ownership.py @@ -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", +] diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 9885c18..74cc406 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -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() diff --git a/src/govoplan_core/server/app.py b/src/govoplan_core/server/app.py index 98cdd9e..26453a7 100644 --- a/src/govoplan_core/server/app.py +++ b/src/govoplan_core/server/app.py @@ -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: diff --git a/src/govoplan_core/server/ownership.py b/src/govoplan_core/server/ownership.py new file mode 100644 index 0000000..68e14e9 --- /dev/null +++ b/src/govoplan_core/server/ownership.py @@ -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"] diff --git a/tests/test_ownership.py b/tests/test_ownership.py new file mode 100644 index 0000000..048890b --- /dev/null +++ b/tests/test_ownership.py @@ -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() diff --git a/tests/test_ownership_api.py b/tests/test_ownership_api.py new file mode 100644 index 0000000..65fe130 --- /dev/null +++ b/tests/test_ownership_api.py @@ -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() diff --git a/tests/test_wheel_runtime.py b/tests/test_wheel_runtime.py index 5050227..79d2b35 100644 --- a/tests/test_wheel_runtime.py +++ b/tests/test_wheel_runtime.py @@ -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"]) diff --git a/webui/src/api/ownership.ts b/webui/src/api/ownership.ts new file mode 100644 index 0000000..52b0b1b --- /dev/null +++ b/webui/src/api/ownership.ts @@ -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; +}; + +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>; + 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; + 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 { + 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 { + return apiFetch( + settings, + "/api/v1/ownership/transfers", + { + method: "POST", + body: JSON.stringify(payload) + } + ); +} + +export function requestResourceOwnership( + settings: ApiSettings, + payload: OwnershipRequestStart +): Promise { + return apiFetch( + 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 { + return apiFetch( + settings, + `/api/v1/ownership/transfers/${transferId}/${action}`, + { method: "POST" } + ); +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 871fac4..7a69739 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/layout/TreeSubnav.tsx b/webui/src/layout/TreeSubnav.tsx new file mode 100644 index 0000000..15fe4e3 --- /dev/null +++ b/webui/src/layout/TreeSubnav.tsx @@ -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 = { + id: T; + label: string; + subtle?: boolean; +}; + +export type TreeSubnavBranch = { + branchId: string; + label: string; + children: TreeSubnavNode[]; + defaultExpanded?: boolean; +}; + +export type TreeSubnavNode = + | TreeSubnavItem + | TreeSubnavBranch; + +function isBranch( + node: TreeSubnavNode +): node is TreeSubnavBranch { + return "branchId" in node; +} + +function branchIdsContaining( + nodes: TreeSubnavNode[], + 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( + nodes: TreeSubnavNode[] +): string[] { + return nodes.flatMap((node) => { + if (!isBranch(node)) return []; + return [ + ...(node.defaultExpanded === false ? [] : [node.branchId]), + ...defaultExpandedBranchIds(node.children) + ]; + }); +} + +export default function TreeSubnav({ + active, + nodes, + onSelect, + ariaLabel, + className = "" +}: { + active: T; + nodes: TreeSubnavNode[]; + onSelect: (section: T) => void; + ariaLabel: string; + className?: string; +}) { + const { translateText } = usePlatformLanguage(); + const initialExpanded = useMemo( + () => defaultExpandedBranchIds(nodes), + [nodes] + ); + const [expanded, setExpanded] = useState>( + () => 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[], + 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 ( +
+ + {isExpanded && ( +
+ {renderNodes(node.children, depth + 1)} +
+ )} +
+ ); + } + return ( + + ); + }); + } + + return ( + + ); +} diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index c871f52..d37b738 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -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; } diff --git a/webui/src/types.ts b/webui/src/types.ts index 50f0cca..05f578c 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -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[];