feat: add governed ownership workflows and admin tree navigation

This commit is contained in:
2026-07-30 17:42:05 +02:00
parent 9b88ae388b
commit f0898fcdee
17 changed files with 2857 additions and 1 deletions

View File

@@ -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

View File

@@ -12,6 +12,7 @@ except ModuleNotFoundError as exc:
raise raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.db.base import Base from govoplan_core.db.base import Base

View 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")

View File

@@ -1,10 +1,40 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox" CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
@dataclass(frozen=True, slots=True)
class NotificationMailDeliveryRequest:
tenant_id: str
notification_id: str
recipient: str
subject: str
body_text: str
body_html: str | None = None
action_url: str | None = None
mail_profile_id: str | None = None
from_address: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class NotificationMailDeliveryProvider(Protocol):
"""Mail-owned durable submission boundary for notification email."""
def submit_notification_mail(
self,
session: object,
request: NotificationMailDeliveryRequest,
) -> Mapping[str, object]:
...
@runtime_checkable @runtime_checkable
@@ -28,3 +58,21 @@ class MailDeliveryOutboxProvider(Protocol):
limit: int = 250, limit: int = 250,
) -> Mapping[str, object]: ) -> Mapping[str, object]:
... ...
def notification_mail_delivery_provider(
registry: object | None,
) -> NotificationMailDeliveryProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
if not isinstance(provider, NotificationMailDeliveryProvider):
raise TypeError(
"mail.notificationDelivery provider does not implement "
"NotificationMailDeliveryProvider"
)
return provider

View File

@@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -405,6 +406,7 @@ class ModuleManifest:
nav_items: tuple[NavItem, ...] = () nav_items: tuple[NavItem, ...] = ()
frontend: FrontendModule | None = None frontend: FrontendModule | None = None
resource_acl_providers: tuple[ResourceAclProvider, ...] = () resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = () tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = () tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict) delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)

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

View File

@@ -25,6 +25,10 @@ from govoplan_core.core.modules import (
TenantSummaryProvider, TenantSummaryProvider,
user_workflow_scope_condition_issues, user_workflow_scope_condition_issues,
) )
from govoplan_core.core.ownership import (
OwnershipProviderRegistration,
ResourceOwnershipProvider,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import ( from govoplan_core.core.search import (
RegisteredSearchProvider, RegisteredSearchProvider,
@@ -65,6 +69,7 @@ class PlatformRegistry:
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {} self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {} self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list) self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
self._capability_factories: dict[str, CapabilityFactory] = {} self._capability_factories: dict[str, CapabilityFactory] = {}
self._capabilities: dict[str, object] = {} self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None self._capability_context: ModuleContext | None = None
@@ -86,6 +91,8 @@ class PlatformRegistry:
for resource_type, providers in manifest.delete_veto_providers.items(): for resource_type, providers in manifest.delete_veto_providers.items():
for provider in providers: for provider in providers:
self.register_delete_veto(manifest.id, resource_type, provider) self.register_delete_veto(manifest.id, resource_type, provider)
for registration in manifest.ownership_providers:
self.register_ownership_provider(manifest.id, registration)
for name, factory in manifest.capability_factories.items(): for name, factory in manifest.capability_factories.items():
self.register_capability_factory(manifest.id, name, factory) self.register_capability_factory(manifest.id, name, factory)
for registration in manifest.search_providers: for registration in manifest.search_providers:
@@ -121,6 +128,7 @@ class PlatformRegistry:
resource_type: list(providers) resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items() for resource_type, providers in replacement._delete_veto_providers.items()
}) })
self._ownership_providers = dict(replacement._ownership_providers)
self._capability_factories = dict(replacement._capability_factories) self._capability_factories = dict(replacement._capability_factories)
self._search_provider_registrations = list( self._search_provider_registrations = list(
replacement._search_provider_registrations replacement._search_provider_registrations
@@ -171,6 +179,44 @@ class PlatformRegistry:
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]: def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers) return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
def register_ownership_provider(
self,
module_id: str,
registration: OwnershipProviderRegistration,
) -> None:
resource_type = registration.resource_type.strip().lower()
if not resource_type:
raise RegistryError(
f"Ownership provider in {module_id} has no resource type"
)
if resource_type in self._ownership_providers:
raise RegistryError(
f"Duplicate ownership provider for resource type: {resource_type}"
)
if not isinstance(registration.provider, ResourceOwnershipProvider):
raise RegistryError(
f"Ownership provider for {resource_type} does not implement "
"ResourceOwnershipProvider"
)
self._ownership_providers[resource_type] = OwnershipProviderRegistration(
resource_type=resource_type,
provider=registration.provider,
module_id=module_id,
)
def ownership_provider(
self,
resource_type: str,
) -> ResourceOwnershipProvider | None:
registration = self.ownership_provider_registration(resource_type)
return registration.provider if registration else None
def ownership_provider_registration(
self,
resource_type: str,
) -> OwnershipProviderRegistration | None:
return self._ownership_providers.get(resource_type.strip().lower())
def configure_capability_context(self, context: ModuleContext) -> None: def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context self._capability_context = context
self._capabilities.clear() self._capabilities.clear()

View File

@@ -9,6 +9,7 @@ from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.credentials import router as credential_router from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.ownership import router as ownership_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions from govoplan_core.server.route_validation import validate_no_route_collisions
@@ -69,6 +70,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
api_router.include_router(router) api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings)) api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(credential_router) api_router.include_router(credential_router)
api_router.include_router(ownership_router)
for router in server_config.post_module_routers: for router in server_config.post_module_routers:
api_router.include_router(router) api_router.include_router(router)
for contribution in server_config.extra_routers: for contribution in server_config.extra_routers:

View 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
View 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
View 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()

View File

@@ -69,7 +69,7 @@ class WheelRuntimeTests(unittest.TestCase):
self.assertNotEqual(repository_root, runtime_root) self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file()) self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file()) self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertEqual(["c91f0a72be34"], result["heads"]) self.assertEqual(["d03a7b9c1e5f"], result["heads"])
self.assertIn("core_scopes", result["tables"]) self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"]) self.assertIn("core_system_settings", result["tables"])

142
webui/src/api/ownership.ts Normal file
View 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" }
);
}

View File

@@ -183,4 +183,24 @@ export { default as IconRail } from "./layout/IconRail";
export { default as LanguageMenu } from "./layout/LanguageMenu"; export { default as LanguageMenu } from "./layout/LanguageMenu";
export { default as ModuleSubnav } from "./layout/ModuleSubnav"; export { default as ModuleSubnav } from "./layout/ModuleSubnav";
export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav"; export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav";
export { default as TreeSubnav } from "./layout/TreeSubnav";
export type {
TreeSubnavBranch,
TreeSubnavItem,
TreeSubnavNode
} from "./layout/TreeSubnav";
export {
decideOwnershipTransfer,
listOwnershipTransfers,
requestResourceOwnership,
startOwnershipTransfer
} from "./api/ownership";
export type {
OwnershipDecision,
OwnershipRequestStart,
OwnershipResource,
OwnershipSubject,
OwnershipTransfer,
OwnershipTransferStart
} from "./api/ownership";
export { default as Titlebar } from "./layout/Titlebar"; export { default as Titlebar } from "./layout/Titlebar";

View 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>
);
}

View File

@@ -73,6 +73,48 @@
.section-link:hover, .section-link.active { background: var(--sidebar-hover-bg); color: var(--text-hover-strong); } .section-link:hover, .section-link.active { background: var(--sidebar-hover-bg); color: var(--text-hover-strong); }
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; } .section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
.section-link.subtle { font-size: 13px; } .section-link.subtle { font-size: 13px; }
.tree-subnav { padding: 10px 0; }
.tree-subnav-disclosure,
.tree-subnav-link {
width: calc(100% + 3px);
min-height: 38px;
box-sizing: border-box;
border: 0;
margin-left: -3px;
padding: 7px 10px 7px calc(12px + (var(--tree-depth, 0) * 14px));
background: transparent;
color: var(--text-subtle);
font: inherit;
text-align: left;
cursor: pointer;
}
.tree-subnav-disclosure {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-strong);
font-size: 13px;
font-weight: 700;
}
.tree-subnav-disclosure svg { flex: 0 0 auto; }
.tree-subnav-link {
padding-left: calc(34px + (var(--tree-depth, 0) * 14px));
font-size: 13px;
}
.tree-subnav-disclosure:hover,
.tree-subnav-link:hover,
.tree-subnav-link.active {
background: var(--sidebar-hover-bg);
color: var(--text-hover-strong);
}
.tree-subnav-link.active {
border-left: 3px solid var(--accent);
font-weight: 700;
}
.tree-subnav-link.subtle { color: var(--muted); }
.tree-subnav-branch.contains-active > .tree-subnav-disclosure {
color: var(--text-strong);
}
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; } .workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
.page-scroll-viewport { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: auto; } .page-scroll-viewport { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: auto; }
.ui-no-sticky-section-sidebars .app-content { overflow: auto; } .ui-no-sticky-section-sidebars .app-content { overflow: auto; }

View File

@@ -283,6 +283,12 @@ export type AdminSectionRenderContext = PlatformRouteContext & {
export type AdminSectionContribution = { export type AdminSectionContribution = {
id: string; id: string;
label: string; label: string;
/** Owning module, used to group module-specific administration. */
moduleId?: string;
/** Distinguishes operational management pages from configurable settings. */
kind?: "management" | "settings";
/** Optional parent section for future embedded or nested settings surfaces. */
parentId?: string;
group?: AdminSectionGroup; group?: AdminSectionGroup;
order?: number; order?: number;
anyOf?: string[]; anyOf?: string[];