tenant-engine has made grouping mutable through its own reclassification route, so a tenant created as tenant:small:acme can report grouping "large". The identifier's grouping segment is now historical and must not be parsed. TenantRecord dropped the field entirely, so the portal read discarded the one safe source of a tenant's classification and left an operator with nothing but the identifier to infer from — exactly the mistake the change creates. The record and adapter now carry grouping, the operator screen shows it with a note that the identifier segment is not the grouping, and the OpenAPI schema documents where to read it. Also corrects the UpdateTenant description, which still claimed grouping was immutable. It is mutable, but never as metadata, because it resolves a tenant's spend ceiling. No reclassification control is offered here: that route is not deployed yet and, per tenant-engine, wants its own permission rather than riding on rename. Full suite: 149 tests, 3 provider-gated skips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
575 lines
19 KiB
Python
575 lines
19 KiB
Python
"""Implementation ports for user-engine adapters.
|
|
|
|
The domain layer should depend on these protocols. Concrete implementations
|
|
can be local test adapters, HTTP clients, database-backed stores, or platform
|
|
adapters without changing domain code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import AbstractContextManager
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable, Mapping, Protocol
|
|
|
|
from user_engine.domain import (
|
|
Account,
|
|
AccessControlFact,
|
|
AccessProfile,
|
|
ActiveAccessContext,
|
|
Actor,
|
|
Application,
|
|
ApplicationBinding,
|
|
AuditRecord,
|
|
AuthorizationDecision,
|
|
AuthorizationRequest,
|
|
CanonEntityReference,
|
|
Catalog,
|
|
ExternalIdentity,
|
|
FactorVerification,
|
|
FamilyInvitation,
|
|
IdentityFactor,
|
|
Membership,
|
|
OnboardingJourney,
|
|
OutboxEvent,
|
|
PreparedAccount,
|
|
ProfileValue,
|
|
RegistrationSession,
|
|
TenantAccount,
|
|
User,
|
|
WelcomeProtocol,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvisioningRequest:
|
|
"""Provider-neutral identity lifecycle request.
|
|
|
|
``idempotency_key`` is mandatory so provider adapters can safely resume
|
|
after timeouts without creating duplicate directory identities.
|
|
"""
|
|
|
|
user_id: str
|
|
tenant: str
|
|
primary_email: str | None
|
|
display_name: str | None
|
|
idempotency_key: str
|
|
correlation_id: str
|
|
roles: tuple[str, ...] = ()
|
|
preferred_username: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RegistrationVerificationRequest:
|
|
"""Non-secret request for an external mailbox-control challenge."""
|
|
|
|
registration_id: str
|
|
normalized_email: str
|
|
preferred_username: str
|
|
client_id: str
|
|
tenant: str
|
|
correlation_id: str
|
|
display_name: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RegistrationVerificationReceipt:
|
|
"""Opaque receipt safe to use for correlation, not authentication."""
|
|
|
|
request_id: str
|
|
accepted: bool = True
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VerifiedRegistrationApplicant:
|
|
"""Purpose-bound evidence returned after consuming a single-use handle."""
|
|
|
|
verification_id: str
|
|
registration_id: str
|
|
normalized_email: str
|
|
preferred_username: str
|
|
client_id: str
|
|
tenant: str
|
|
source_system: str
|
|
assurance: Mapping[str, Any]
|
|
display_name: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvisioningResult:
|
|
provider: str
|
|
external_subject: str
|
|
status: str
|
|
resumed: bool = False
|
|
password_setup_url: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IdentityDriftResult:
|
|
provider: str
|
|
external_subject: str
|
|
status: str
|
|
drift: tuple[str, ...] = ()
|
|
changed: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TenantProvisioningResult:
|
|
tenant: str
|
|
status: str
|
|
resumed: bool = False
|
|
external_ref: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TenantRecord:
|
|
"""Authoritative tenant state read back from the tenant authority."""
|
|
|
|
tenant: str
|
|
external_ref: str
|
|
lifecycle: str
|
|
version: int
|
|
# Authoritative classification. The identifier's own grouping segment is
|
|
# historical after a reclassification, so it must never be parsed for this.
|
|
grouping: str | None = None
|
|
display_name: str | None = None
|
|
contact_email: str | None = None
|
|
retired_at: str | None = None
|
|
reactivated_at: str | None = None
|
|
replayed: bool = False
|
|
|
|
|
|
class TenantManagementPort(Protocol):
|
|
"""Provider-neutral seam to the tenant authority (normally tenant-engine)."""
|
|
|
|
def create_tenant(
|
|
self, *, tenant: str, display_name: str, idempotency_key: str,
|
|
correlation_id: str,
|
|
) -> TenantProvisioningResult:
|
|
"""Create or resume a tenant without making user-engine authoritative."""
|
|
|
|
def tenant(self, *, tenant: str, correlation_id: str) -> TenantRecord:
|
|
"""Read the authoritative record and the version to echo on a mutation."""
|
|
|
|
def update_tenant(
|
|
self, *, tenant: str, metadata: Mapping[str, str], expected_version: int,
|
|
reason: str, idempotency_key: str, correlation_id: str,
|
|
) -> TenantRecord:
|
|
"""Change allow-listed metadata under an atomic compare-and-swap."""
|
|
|
|
def retire_tenant(
|
|
self, *, tenant: str, expected_version: int, reason: str,
|
|
idempotency_key: str, correlation_id: str,
|
|
) -> TenantRecord:
|
|
"""Reversibly retire a tenant; the authority never hard-deletes."""
|
|
|
|
def reactivate_tenant(
|
|
self, *, tenant: str, expected_version: int, reason: str,
|
|
idempotency_key: str, correlation_id: str,
|
|
) -> TenantRecord:
|
|
"""Restore a retired tenant without resurrecting revoked grants."""
|
|
|
|
|
|
class IdentityProvisioningPort(Protocol):
|
|
"""Lifecycle seam owned by NetKingdom adapters, not the user domain."""
|
|
|
|
def provision(self, request: ProvisioningRequest) -> ProvisioningResult:
|
|
"""Create or resume an external login identity."""
|
|
|
|
def suspend(
|
|
self, *, external_subject: str, idempotency_key: str, correlation_id: str
|
|
) -> ProvisioningResult:
|
|
"""Disable authentication while retaining recoverable identity state."""
|
|
|
|
def reactivate(
|
|
self, *, external_subject: str, idempotency_key: str, correlation_id: str
|
|
) -> ProvisioningResult:
|
|
"""Re-enable a previously suspended identity."""
|
|
|
|
def deprovision(
|
|
self, *, external_subject: str, idempotency_key: str, correlation_id: str
|
|
) -> ProvisioningResult:
|
|
"""Remove or tombstone an identity according to provider policy."""
|
|
|
|
def drift(
|
|
self,
|
|
request: ProvisioningRequest,
|
|
*,
|
|
external_subject: str,
|
|
desired_status: str = "active",
|
|
) -> IdentityDriftResult:
|
|
"""Inspect provider state without changing it or exposing credentials."""
|
|
|
|
def reconcile(
|
|
self,
|
|
request: ProvisioningRequest,
|
|
*,
|
|
external_subject: str,
|
|
desired_status: str = "active",
|
|
) -> IdentityDriftResult:
|
|
"""Converge managed provider state toward the requested lifecycle."""
|
|
|
|
|
|
class RegistrationVerificationPort(Protocol):
|
|
"""Mailbox verification issuer; token plaintext never enters domain state."""
|
|
|
|
def request(
|
|
self, request: RegistrationVerificationRequest
|
|
) -> RegistrationVerificationReceipt:
|
|
"""Request an anti-enumerating, purpose-bound verification message."""
|
|
|
|
def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
|
|
"""Atomically consume verified, unexpired applicant evidence."""
|
|
|
|
def cancel(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
|
|
"""Atomically cancel an unexpired applicant intent using mailbox evidence."""
|
|
|
|
|
|
class UserEngineStore(Protocol):
|
|
"""Durable persistence boundary for user-engine service behavior.
|
|
|
|
Implementations may be in-memory, Postgres-backed, or platform-provided,
|
|
but must preserve the same logical keys, readiness contract, and atomic
|
|
mutation semantics exposed here.
|
|
"""
|
|
|
|
schema_version: str | None
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Return whether the store is schema-compatible for service use."""
|
|
|
|
def migrate(self) -> None:
|
|
"""Apply or verify user-engine-owned schema migrations."""
|
|
|
|
def transaction(self) -> AbstractContextManager[None]:
|
|
"""Return a context manager for one atomic mutation unit."""
|
|
|
|
def save_user(self, user: User) -> None:
|
|
"""Create or replace a user record."""
|
|
|
|
def user(self, user_id: str) -> User | None:
|
|
"""Return a user by id."""
|
|
|
|
def save_account(self, account: Account) -> None:
|
|
"""Create or replace a primary account record."""
|
|
|
|
def user_account(self, user_id: str) -> Account | None:
|
|
"""Return the primary account for a user."""
|
|
|
|
def save_identity(self, identity: ExternalIdentity) -> None:
|
|
"""Create or replace an external identity link."""
|
|
|
|
def find_identity(self, issuer: str, subject: str) -> ExternalIdentity | None:
|
|
"""Return an external identity by issuer and subject."""
|
|
|
|
def identities_for_user(self, user_id: str) -> tuple[ExternalIdentity, ...]:
|
|
"""Return all external identities linked to a user."""
|
|
|
|
def save_tenant_account(self, account: TenantAccount) -> None:
|
|
"""Create or replace a tenant-scoped account record."""
|
|
|
|
def tenant_account(self, tenant: str, user_id: str) -> TenantAccount | None:
|
|
"""Return a tenant-scoped account record."""
|
|
|
|
def save_membership(self, membership: Membership) -> None:
|
|
"""Create or replace a membership fact."""
|
|
|
|
def memberships_for_user(
|
|
self, user_id: str, *, tenant: str | None = None
|
|
) -> tuple[Membership, ...]:
|
|
"""Return memberships for a user, optionally scoped to a tenant."""
|
|
|
|
def memberships_for_tenant(self, tenant: str) -> tuple[Membership, ...]:
|
|
"""Return memberships scoped to a tenant."""
|
|
|
|
def save_application(self, application: Application) -> None:
|
|
"""Create or replace an application registration."""
|
|
|
|
def application(self, application_id: str) -> Application | None:
|
|
"""Return an application by id."""
|
|
|
|
def save_binding(self, binding: ApplicationBinding) -> None:
|
|
"""Create or replace an application binding."""
|
|
|
|
def binding(self, application_id: str) -> ApplicationBinding | None:
|
|
"""Return an application binding by application id."""
|
|
|
|
def save_catalog(self, catalog: Catalog) -> None:
|
|
"""Create or replace a catalog."""
|
|
|
|
def catalog(self, catalog_id: str) -> Catalog | None:
|
|
"""Return a catalog by id."""
|
|
|
|
def all_catalogs(self) -> tuple[Catalog, ...]:
|
|
"""Return all catalogs."""
|
|
|
|
def save_family_invitation(self, invitation: FamilyInvitation) -> None:
|
|
"""Create or replace a family invitation."""
|
|
|
|
def family_invitation(self, invitation_id: str) -> FamilyInvitation | None:
|
|
"""Return a family invitation by id."""
|
|
|
|
def family_invitations_for_user(
|
|
self, user_id: str
|
|
) -> tuple[FamilyInvitation, ...]:
|
|
"""Return family invitations for a user."""
|
|
|
|
def family_invitations_for_tenant(
|
|
self, tenant: str
|
|
) -> tuple[FamilyInvitation, ...]:
|
|
"""Return invitations visible in one tenant."""
|
|
|
|
def save_registration_session(self, session: RegistrationSession) -> None:
|
|
"""Create or replace a registration session."""
|
|
|
|
def registration_session(
|
|
self, registration_id: str
|
|
) -> RegistrationSession | None:
|
|
"""Return a registration session by id."""
|
|
|
|
def all_registration_sessions(self) -> tuple[RegistrationSession, ...]:
|
|
"""Return all registration sessions."""
|
|
|
|
def save_identity_factor(self, factor: IdentityFactor) -> None:
|
|
"""Create or replace a verified identity factor."""
|
|
|
|
def identity_factor(self, factor_id: str) -> IdentityFactor | None:
|
|
"""Return a verified identity factor by id."""
|
|
|
|
def factors_for_registration(
|
|
self, registration_id: str
|
|
) -> tuple[IdentityFactor, ...]:
|
|
"""Return verified factors attached to a registration session."""
|
|
|
|
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
|
|
"""Return verified factors attached to a user."""
|
|
|
|
def save_prepared_account(self, account: PreparedAccount) -> None:
|
|
"""Create or replace a prepared account package."""
|
|
|
|
def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None:
|
|
"""Return a prepared account package by id."""
|
|
|
|
def prepared_accounts_for_tenant(
|
|
self, tenant: str
|
|
) -> tuple[PreparedAccount, ...]:
|
|
"""Return prepared account packages for a tenant."""
|
|
|
|
def save_access_profile(self, profile: AccessProfile) -> None:
|
|
"""Create or replace an access profile template."""
|
|
|
|
def access_profile(self, access_profile_id: str) -> AccessProfile | None:
|
|
"""Return an access profile template by id."""
|
|
|
|
def access_profiles_for_tenant(self, tenant: str) -> tuple[AccessProfile, ...]:
|
|
"""Return access profile templates for a tenant."""
|
|
|
|
def save_active_access_context(self, context: ActiveAccessContext) -> None:
|
|
"""Create or replace the user's active access context for a tenant."""
|
|
|
|
def active_access_context(
|
|
self, user_id: str, tenant: str
|
|
) -> ActiveAccessContext | None:
|
|
"""Return the user's active access context for a tenant."""
|
|
|
|
def active_access_contexts_for_tenant(
|
|
self, tenant: str
|
|
) -> tuple[ActiveAccessContext, ...]:
|
|
"""Return active access contexts for a tenant."""
|
|
|
|
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
|
|
"""Create or replace a welcome protocol template."""
|
|
|
|
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
|
|
"""Return a welcome protocol template by id."""
|
|
|
|
def welcome_protocols_for_tenant(
|
|
self, tenant: str
|
|
) -> tuple[WelcomeProtocol, ...]:
|
|
"""Return welcome protocol templates for a tenant."""
|
|
|
|
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
|
|
"""Create or replace an onboarding journey."""
|
|
|
|
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
|
|
"""Return an onboarding journey by id."""
|
|
|
|
def onboarding_journeys_for_user(
|
|
self, user_id: str, *, tenant: str | None = None
|
|
) -> tuple[OnboardingJourney, ...]:
|
|
"""Return onboarding journeys for a user."""
|
|
|
|
def onboarding_journeys_for_tenant(
|
|
self, tenant: str
|
|
) -> tuple[OnboardingJourney, ...]:
|
|
"""Return onboarding journeys for a tenant."""
|
|
|
|
def save_profile_value(self, value: ProfileValue) -> None:
|
|
"""Create or replace a profile value."""
|
|
|
|
def values_for_user(self, user_id: str) -> tuple[ProfileValue, ...]:
|
|
"""Return profile values for a user."""
|
|
|
|
def append_audit(self, record: AuditRecord) -> None:
|
|
"""Append a local audit record."""
|
|
|
|
def audit_log(self) -> tuple[AuditRecord, ...]:
|
|
"""Return local audit records in write order."""
|
|
|
|
def append_outbox(self, event: OutboxEvent) -> None:
|
|
"""Append an outbox event."""
|
|
|
|
def pending_outbox(self) -> tuple[OutboxEvent, ...]:
|
|
"""Return pending outbox events in write order."""
|
|
|
|
def save_outbox(self, event: OutboxEvent) -> None:
|
|
"""Persist outbox delivery state."""
|
|
|
|
def outbox_event(self, event_id: str) -> OutboxEvent | None:
|
|
"""Return an outbox event including delivery state."""
|
|
|
|
def record_counts(self) -> Mapping[str, int]:
|
|
"""Return adapter-neutral record counts for diagnostics."""
|
|
|
|
|
|
class IdentityClaimsAdapter(Protocol):
|
|
"""Normalize verified identity claims into a user-engine actor."""
|
|
|
|
def normalize(self, claims: Mapping[str, Any]) -> Actor:
|
|
"""Return a normalized actor from already-verified claims."""
|
|
|
|
def identity_key(self, actor: Actor) -> tuple[str, str]:
|
|
"""Return the stable external identity link key."""
|
|
|
|
|
|
class FactorVerificationAdapter(Protocol):
|
|
"""Normalize external proofing results into safe factor evidence."""
|
|
|
|
def normalize(self, proofing_result: Mapping[str, Any]) -> FactorVerification:
|
|
"""Return normalized verified factor evidence without secret payloads."""
|
|
|
|
|
|
class AuthorizationCheckPort(Protocol):
|
|
"""Ask whether an actor may perform an action."""
|
|
|
|
def check(self, request: AuthorizationRequest) -> AuthorizationDecision:
|
|
"""Return the authorization decision for one request."""
|
|
|
|
def batch_check(
|
|
self, requests: Iterable[AuthorizationRequest]
|
|
) -> tuple[AuthorizationDecision, ...]:
|
|
"""Return decisions in request order."""
|
|
|
|
|
|
class ApplicationBindingStore(Protocol):
|
|
"""Store links between user-engine applications and external systems."""
|
|
|
|
def get(self, application_id: str) -> ApplicationBinding | None:
|
|
"""Return a binding by user-engine application id."""
|
|
|
|
def save(self, binding: ApplicationBinding) -> None:
|
|
"""Create or replace an application binding."""
|
|
|
|
|
|
class MembershipFactExporter(Protocol):
|
|
"""Export membership facts as read models for authorization systems."""
|
|
|
|
def export(self, memberships: Iterable[Membership]) -> Mapping[str, Any]:
|
|
"""Return an adapter-neutral membership fact manifest."""
|
|
|
|
|
|
class AccessControlFactExporter(Protocol):
|
|
"""Export access-control facts to an external policy or ACL system."""
|
|
|
|
def export(self, facts: Iterable[AccessControlFact]) -> Mapping[str, Any]:
|
|
"""Return an adapter-neutral access-control fact manifest."""
|
|
|
|
|
|
class OnboardingNotificationPort(Protocol):
|
|
"""Notify a delivery system about onboarding journey state."""
|
|
|
|
def notify(self, journey: OnboardingJourney) -> Mapping[str, Any]:
|
|
"""Return adapter metadata for a notification request."""
|
|
|
|
|
|
class OnboardingTaskPort(Protocol):
|
|
"""Create or link external lifecycle tasks for onboarding steps."""
|
|
|
|
def link_task(self, journey: OnboardingJourney, step_key: str) -> Mapping[str, Any]:
|
|
"""Return task-link metadata for one onboarding step."""
|
|
|
|
|
|
class SupportContentPort(Protocol):
|
|
"""Resolve support or help content references for onboarding."""
|
|
|
|
def content_ref(self, protocol: WelcomeProtocol, step_key: str) -> str | None:
|
|
"""Return an adapter-owned content reference for a protocol step."""
|
|
|
|
|
|
class SubsystemWelcomePort(Protocol):
|
|
"""Call a protected subsystem welcome callback."""
|
|
|
|
def start(self, journey: OnboardingJourney, step_key: str) -> Mapping[str, Any]:
|
|
"""Return callback metadata for a subsystem welcome step."""
|
|
|
|
|
|
class LifecycleTaskLinkPort(Protocol):
|
|
"""Link onboarding journeys to external lifecycle task systems."""
|
|
|
|
def link(self, journey: OnboardingJourney) -> Mapping[str, Any]:
|
|
"""Return lifecycle task references for an onboarding journey."""
|
|
|
|
|
|
class EventOutbox(Protocol):
|
|
"""Persist and publish durable domain events."""
|
|
|
|
def append(self, event: OutboxEvent) -> None:
|
|
"""Append an event in the same unit of work as its mutation."""
|
|
|
|
def pending(self) -> tuple[OutboxEvent, ...]:
|
|
"""Return events waiting for delivery."""
|
|
|
|
|
|
class AuditWriter(Protocol):
|
|
"""Persist local audit records and support platform audit export."""
|
|
|
|
def record(self, audit_record: AuditRecord) -> None:
|
|
"""Persist an audit record."""
|
|
|
|
|
|
class EvidenceReferenceExporter(Protocol):
|
|
"""Export audit/review material as identity-canon evidence references."""
|
|
|
|
def export(
|
|
self, audit_records: Iterable[AuditRecord]
|
|
) -> tuple[CanonEntityReference, ...]:
|
|
"""Return evidence references without owning the platform audit sink."""
|
|
|
|
|
|
class PolicyControlReferenceResolver(Protocol):
|
|
"""Resolve policy/control references for identity-domain traces."""
|
|
|
|
def references_for(
|
|
self, request: AuthorizationRequest, decision: AuthorizationDecision
|
|
) -> Mapping[str, CanonEntityReference]:
|
|
"""Return policy, control, review, or exception references when known."""
|
|
|
|
|
|
class LifecycleTaskSink(Protocol):
|
|
"""Handoff identity-domain gaps or lifecycle work to a task system."""
|
|
|
|
def create_or_link(
|
|
self,
|
|
*,
|
|
summary: str,
|
|
subject: CanonEntityReference,
|
|
evidence: Iterable[CanonEntityReference] = (),
|
|
) -> CanonEntityReference:
|
|
"""Return the task reference created or linked by the downstream system."""
|
|
|
|
|
|
class SecretProvider(Protocol):
|
|
"""Load runtime secret material from the active environment."""
|
|
|
|
def get(self, name: str) -> str:
|
|
"""Return a secret value by logical name."""
|